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 sandbox_setup_refusal(ctx, principal, error);
1094            }
1095        };
1096        let launcher_path = match std::env::current_exe() {
1097            Ok(path) => path,
1098            Err(error) => {
1099                let _ = std::fs::remove_dir_all(&profile.temp_dir);
1100                return sandbox_setup_refusal(
1101                    ctx,
1102                    principal,
1103                    format!("failed to locate the aft executable: {error}"),
1104                );
1105            }
1106        };
1107        crate::slog_info!(
1108            "sandbox profile apply: tier=native task_kind={task_kind:?} writable_roots={} read_deny={}",
1109            profile.writable_roots.len(),
1110            profile.read_deny.len()
1111        );
1112        crate::slog_debug!(
1113            "sandbox profile paths: tier=native writable_roots={:?} write_deny_nested={:?} read_deny={:?} socket_deny={:?} cache_roots={:?} temp_dir={:?}",
1114            profile.writable_roots,
1115            profile.write_deny_nested,
1116            profile.read_deny,
1117            profile.socket_deny,
1118            profile.cache_roots,
1119            profile.temp_dir
1120        );
1121        SpawnPlan::Launcher {
1122            profile,
1123            launcher_path,
1124        }
1125    }
1126
1127    #[cfg(all(not(unix), not(windows)))]
1128    {
1129        let _ = (ctx, principal, task_kind, task_bundle_dir);
1130        unreachable!("unsupported platforms return before native-tier resolution")
1131    }
1132}
1133
1134#[cfg(unix)]
1135fn sandbox_setup_refusal(
1136    ctx: &AppContext,
1137    principal: &AuthenticatedPrincipal,
1138    cause: impl std::fmt::Display,
1139) -> SpawnPlan {
1140    let config = ctx.config();
1141    let root = config
1142        .project_root
1143        .as_deref()
1144        .map_or_else(|| Path::new("<unknown>"), |root| root);
1145    let session = match principal {
1146        AuthenticatedPrincipal::RouteBind { session_id, .. } => session_id.clone(),
1147        AuthenticatedPrincipal::FirstParty => {
1148            crate::log_ctx::current_session().unwrap_or_else(|| "<unknown>".to_string())
1149        }
1150    };
1151    let message = format!(
1152        "sandbox setup for {} failed: {cause}; set sandbox.enabled=false to disable native sandboxing",
1153        root.display()
1154    );
1155    crate::slog_warn!("{message}; root={} session={session}", root.display());
1156    SpawnPlan::Refused {
1157        code: "sandbox_unavailable",
1158        message,
1159        mismatch_class: None,
1160    }
1161}
1162
1163#[cfg(unix)]
1164fn escalation_refused(refusal: EscalationRefusal) -> SpawnPlan {
1165    let class = refusal.class();
1166    SpawnPlan::Refused {
1167        code: "sandbox_escalation_denied",
1168        message: format!("sandbox host escalation grant refused: {class}"),
1169        mismatch_class: Some(class),
1170    }
1171}
1172
1173#[cfg(unix)]
1174fn build_native_profile(
1175    ctx: &AppContext,
1176    principal: &AuthenticatedPrincipal,
1177    task_bundle_dir: &Path,
1178) -> Result<SandboxProfile, String> {
1179    let home = std::env::var_os("HOME")
1180        .filter(|value| !value.is_empty())
1181        .map(PathBuf::from)
1182        .ok_or_else(|| {
1183            "HOME is not set, so credential and cache paths cannot be resolved".to_string()
1184        })?;
1185    if !home.is_absolute() {
1186        return Err(format!("HOME must be absolute: {}", home.display()));
1187    }
1188    let home = home
1189        .canonicalize()
1190        .map_err(|error| format!("failed to canonicalize HOME {}: {error}", home.display()))?;
1191    if !home.is_dir() {
1192        return Err(format!("HOME is not a directory: {}", home.display()));
1193    }
1194
1195    let mut project_roots = Vec::new();
1196    if let Some(root) = &ctx.config().project_root {
1197        project_roots.push(root.clone());
1198    }
1199    if let AuthenticatedPrincipal::RouteBind { project_root, .. } = principal {
1200        if !project_roots.contains(project_root) {
1201            project_roots.push(project_root.clone());
1202        }
1203    }
1204    if project_roots.is_empty() {
1205        project_roots.push(
1206            std::env::current_dir()
1207                .map_err(|error| format!("failed to resolve the current project root: {error}"))?,
1208        );
1209    }
1210
1211    for root in &mut project_roots {
1212        if !root.is_dir() {
1213            return Err(format!(
1214                "project root is not an existing directory: {}",
1215                root.display()
1216            ));
1217        }
1218        *root = root.canonicalize().map_err(|error| {
1219            format!(
1220                "failed to canonicalize project root {}: {error}",
1221                root.display()
1222            )
1223        })?;
1224    }
1225    project_roots.sort_unstable();
1226    project_roots.dedup();
1227
1228    if !task_bundle_dir.is_dir() {
1229        return Err(format!(
1230            "task io directory is not an existing directory: {}",
1231            task_bundle_dir.display()
1232        ));
1233    }
1234    let task_io_dir = task_bundle_dir
1235        .canonicalize()
1236        .map_err(|error| format!("failed to canonicalize task io directory: {error}"))?;
1237    let session_store = session_store_for_task_io(&task_io_dir)?;
1238
1239    let git_policies = project_roots
1240        .iter()
1241        .map(|root| resolve_git_policy(root))
1242        .collect::<Result<Vec<_>, _>>()?;
1243    let temp_dir = create_task_temp_dir(&task_io_dir)?;
1244    let result = (|| {
1245        let mut writable_roots = project_roots.clone();
1246        writable_roots.push(task_io_dir.clone());
1247        writable_roots.extend(
1248            ctx.config()
1249                .sandbox
1250                .write_allow
1251                .iter()
1252                .map(|path| expand_home(path, &home)),
1253        );
1254
1255        let secret_floor = vec![
1256            home.join(".ssh"),
1257            home.join(".aws"),
1258            home.join(".gnupg"),
1259            home.join(".config/gcloud"),
1260            home.join(".azure"),
1261            home.join(".config/cortexkit"),
1262        ];
1263        // The credential floor denies both read and write. Linux rejects any
1264        // writable overlap because Landlock cannot subtract write rights.
1265        let write_deny = secret_floor.clone();
1266        #[cfg(target_os = "macos")]
1267        let mut write_deny = write_deny;
1268        let mut write_deny_nested = Vec::new();
1269        let mut read_deny = secret_floor;
1270        for (root, git_policy) in project_roots.iter().zip(&git_policies) {
1271            #[cfg(target_os = "linux")]
1272            write_deny_nested.push(root.join(".git"));
1273            write_deny_nested.push(root.join(".cortexkit"));
1274            #[cfg(target_os = "macos")]
1275            write_deny.extend(git_policy.hooks.iter().cloned());
1276            read_deny.extend(git_policy.hooks.iter().cloned());
1277        }
1278        #[cfg(target_os = "linux")]
1279        read_deny.extend([
1280            PathBuf::from("/run/user"),
1281            PathBuf::from("/run/credentials"),
1282            PathBuf::from("/run/secrets"),
1283        ]);
1284        read_deny.extend(
1285            ctx.config()
1286                .sandbox
1287                .read_deny
1288                .iter()
1289                .map(|path| expand_home(path, &home)),
1290        );
1291
1292        let mut cache_roots = vec![
1293            home.join(".cargo/registry"),
1294            home.join(".cargo/git"),
1295            home.join(".rustup/downloads"),
1296            home.join(".npm"),
1297            home.join(".bun/install/cache"),
1298            home.join(".cache/pip"),
1299            home.join(".cache/uv"),
1300            home.join(".cache/go-build"),
1301            home.join(".gradle/caches"),
1302            home.join(".m2/repository"),
1303        ];
1304        #[cfg(target_os = "macos")]
1305        cache_roots.extend([
1306            home.join("Library/Caches/pip"),
1307            home.join("Library/Caches/uv"),
1308            home.join("Library/Caches/go-build"),
1309        ]);
1310        cache_roots.retain(|path| path.is_dir());
1311
1312        let mut socket_deny = vec![PathBuf::from("/var/run/docker.sock")];
1313        if let Some(agent_socket) =
1314            std::env::var_os("SSH_AUTH_SOCK").filter(|value| !value.is_empty())
1315        {
1316            socket_deny.push(PathBuf::from(agent_socket));
1317        }
1318
1319        let mut profile = SandboxProfile::build(
1320            writable_roots,
1321            write_deny,
1322            write_deny_nested,
1323            Vec::new(),
1324            read_deny,
1325            socket_deny,
1326            cache_roots,
1327            temp_dir.clone(),
1328        )
1329        .map_err(|error| error.to_string())?;
1330        // Seatbelt starts from allow-all reads, so it must deny the complete
1331        // store. Landlock instead omits the store while splitting read grants,
1332        // then adds only the prepared task's exact payload files.
1333        #[cfg(target_os = "macos")]
1334        if !profile.read_deny.contains(&session_store) {
1335            profile.read_deny.push(session_store.clone());
1336        }
1337        refuse_store_overlap(&profile, &session_store, &task_io_dir)?;
1338
1339        #[cfg(target_os = "linux")]
1340        let profile = {
1341            let git_read_roots = git_policies
1342                .iter()
1343                .flat_map(|policy| policy.read_roots.iter().cloned())
1344                .collect::<Vec<_>>();
1345            profile.read_allow = build_linux_read_allow(
1346                &profile,
1347                &home,
1348                &git_read_roots,
1349                std::slice::from_ref(&session_store),
1350            )?;
1351            profile = profile
1352                .canonicalize_for_launch()
1353                .map_err(|error| error.to_string())?;
1354            validate_final_read_rules(&profile.read_allow, &profile.read_deny)?;
1355            assert!(
1356                validate_final_read_rules(&profile.read_allow, &profile.read_deny).is_ok(),
1357                "final Landlock read grants overlap a denied path"
1358            );
1359            profile
1360        };
1361
1362        Ok(profile)
1363    })();
1364    if result.is_err() {
1365        let _ = std::fs::remove_dir_all(&temp_dir);
1366    }
1367    result
1368}
1369
1370#[cfg(unix)]
1371pub(crate) fn control_payload_read_grants(task_io: &Path) -> Result<Vec<PathBuf>, String> {
1372    if task_io.file_name() != Some(OsStr::new("io")) {
1373        return Err("task payload grants require the directory-layout io path".to_string());
1374    }
1375    let task_dir = task_io
1376        .parent()
1377        .ok_or_else(|| "task io directory has no task parent".to_string())?;
1378    let task_id = task_dir
1379        .file_name()
1380        .and_then(OsStr::to_str)
1381        .ok_or_else(|| "task directory has no UTF-8 identity".to_string())?;
1382    crate::bash_background::persistence::validate_task_id(task_id)
1383        .map_err(|error| error.to_string())?;
1384    let control = task_dir.join("control");
1385    Ok(vec![
1386        control.join(crate::bash_background::persistence::COMMAND_FILE),
1387        control.join(crate::bash_background::persistence::WRAPPER_FILE),
1388        control.join(crate::bash_background::persistence::ENVIRONMENT_FILE),
1389    ])
1390}
1391
1392#[cfg(unix)]
1393fn session_store_for_task_io(task_io: &Path) -> Result<PathBuf, String> {
1394    let Some(task_dir) = task_io.parent() else {
1395        return Err("task io directory has no task parent".to_string());
1396    };
1397    let directory_layout = task_io.file_name() == Some(OsStr::new("io"))
1398        && task_dir
1399            .file_name()
1400            .and_then(OsStr::to_str)
1401            .is_some_and(|task_id| {
1402                crate::bash_background::persistence::validate_task_id(task_id).is_ok()
1403            });
1404    let candidate = if directory_layout {
1405        task_dir
1406            .parent()
1407            .ok_or_else(|| "task directory has no session parent".to_string())?
1408    } else {
1409        task_io
1410    };
1411    candidate
1412        .canonicalize()
1413        .map_err(|error| format!("failed to canonicalize bash task session store: {error}"))
1414}
1415
1416#[cfg(unix)]
1417fn refuse_store_overlap(
1418    profile: &SandboxProfile,
1419    session_store: &Path,
1420    task_io: &Path,
1421) -> Result<(), String> {
1422    for root in profile.write_allow_roots() {
1423        if root == task_io || root.starts_with(task_io) {
1424            continue;
1425        }
1426        if root == session_store
1427            || root.starts_with(session_store)
1428            || session_store.starts_with(root)
1429        {
1430            return Err(format!(
1431                "sandbox writable root overlaps the bash task session store: writable={} store={}",
1432                root.display(),
1433                session_store.display()
1434            ));
1435        }
1436    }
1437    Ok(())
1438}
1439
1440#[cfg(unix)]
1441#[derive(Debug)]
1442struct GitPolicy {
1443    #[cfg(target_os = "linux")]
1444    read_roots: Vec<PathBuf>,
1445    hooks: Vec<PathBuf>,
1446}
1447
1448#[cfg(unix)]
1449fn resolve_git_policy(project_root: &Path) -> Result<GitPolicy, String> {
1450    let dot_git = project_root.join(".git");
1451    let metadata = match std::fs::symlink_metadata(&dot_git) {
1452        Ok(metadata) => metadata,
1453        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1454            return Ok(GitPolicy {
1455                #[cfg(target_os = "linux")]
1456                read_roots: Vec::new(),
1457                hooks: vec![dot_git.join("hooks")],
1458            });
1459        }
1460        Err(error) => {
1461            return Err(format!(
1462                "failed to inspect Git metadata {}: {error}",
1463                dot_git.display()
1464            ));
1465        }
1466    };
1467    if metadata.file_type().is_symlink() {
1468        return Err(format!(
1469            "refusing sandbox profile with symlinked Git metadata: {}",
1470            dot_git.display()
1471        ));
1472    }
1473
1474    let git_dir = if metadata.is_dir() {
1475        dot_git.canonicalize().map_err(|error| {
1476            format!(
1477                "failed to canonicalize Git directory {}: {error}",
1478                dot_git.display()
1479            )
1480        })?
1481    } else if metadata.is_file() {
1482        let pointer = std::fs::read_to_string(&dot_git).map_err(|error| {
1483            format!(
1484                "failed to read linked-worktree Git pointer {}: {error}",
1485                dot_git.display()
1486            )
1487        })?;
1488        let pointer = pointer
1489            .trim()
1490            .strip_prefix("gitdir:")
1491            .map(str::trim)
1492            .filter(|path| !path.is_empty())
1493            .ok_or_else(|| {
1494                format!(
1495                    "linked-worktree Git pointer is malformed: {}",
1496                    dot_git.display()
1497                )
1498            })?;
1499        let pointer = PathBuf::from(pointer);
1500        let pointer = if pointer.is_absolute() {
1501            pointer
1502        } else {
1503            project_root.join(pointer)
1504        };
1505        pointer.canonicalize().map_err(|error| {
1506            format!(
1507                "failed to resolve linked-worktree Git directory {}: {error}",
1508                pointer.display()
1509            )
1510        })?
1511    } else {
1512        return Err(format!(
1513            "Git metadata is neither a file nor directory: {}",
1514            dot_git.display()
1515        ));
1516    };
1517    if !git_dir.is_dir() {
1518        return Err(format!(
1519            "resolved Git directory is not a directory: {}",
1520            git_dir.display()
1521        ));
1522    }
1523
1524    let commondir_file = git_dir.join("commondir");
1525    let common_dir = match std::fs::read_to_string(&commondir_file) {
1526        Ok(value) => {
1527            let value = value.trim();
1528            if value.is_empty() {
1529                return Err(format!(
1530                    "Git commondir pointer is empty: {}",
1531                    commondir_file.display()
1532                ));
1533            }
1534            let value = PathBuf::from(value);
1535            let value = if value.is_absolute() {
1536                value
1537            } else {
1538                git_dir.join(value)
1539            };
1540            value.canonicalize().map_err(|error| {
1541                format!(
1542                    "failed to resolve Git commondir {}: {error}",
1543                    value.display()
1544                )
1545            })?
1546        }
1547        Err(error) if error.kind() == std::io::ErrorKind::NotFound => git_dir.clone(),
1548        Err(error) => {
1549            return Err(format!(
1550                "failed to read Git commondir {}: {error}",
1551                commondir_file.display()
1552            ));
1553        }
1554    };
1555    if !common_dir.is_dir() {
1556        return Err(format!(
1557            "resolved Git commondir is not a directory: {}",
1558            common_dir.display()
1559        ));
1560    }
1561
1562    let hooks = resolve_hooks_path(project_root, &common_dir)?;
1563    #[cfg(target_os = "linux")]
1564    let read_roots = {
1565        let mut read_roots = vec![git_dir, common_dir];
1566        read_roots.sort_unstable();
1567        read_roots.dedup();
1568        read_roots
1569    };
1570    Ok(GitPolicy {
1571        #[cfg(target_os = "linux")]
1572        read_roots,
1573        hooks: vec![hooks],
1574    })
1575}
1576
1577#[cfg(unix)]
1578fn resolve_hooks_path(project_root: &Path, common_dir: &Path) -> Result<PathBuf, String> {
1579    let configured = Command::new("git")
1580        .arg("-C")
1581        .arg(project_root)
1582        .args(["config", "--path", "core.hooksPath"])
1583        .output()
1584        .map_err(|error| {
1585            format!(
1586                "failed to query core.hooksPath for {}: {error}",
1587                project_root.display()
1588            )
1589        })?;
1590    if configured.status.success() {
1591        let configured = String::from_utf8(configured.stdout).map_err(|error| {
1592            format!(
1593                "core.hooksPath for {} is not UTF-8: {error}",
1594                project_root.display()
1595            )
1596        })?;
1597        if configured.trim().is_empty() {
1598            return Err(format!(
1599                "core.hooksPath for {} is empty",
1600                project_root.display()
1601            ));
1602        }
1603        let resolved = Command::new("git")
1604            .arg("-C")
1605            .arg(project_root)
1606            .args(["rev-parse", "--path-format=absolute", "--git-path", "hooks"])
1607            .output()
1608            .map_err(|error| {
1609                format!(
1610                    "failed to resolve core.hooksPath for {}: {error}",
1611                    project_root.display()
1612                )
1613            })?;
1614        if !resolved.status.success() {
1615            return Err(format!(
1616                "git could not resolve core.hooksPath for {}: {}",
1617                project_root.display(),
1618                String::from_utf8_lossy(&resolved.stderr).trim()
1619            ));
1620        }
1621        let resolved = String::from_utf8(resolved.stdout).map_err(|error| {
1622            format!(
1623                "resolved core.hooksPath for {} is not UTF-8: {error}",
1624                project_root.display()
1625            )
1626        })?;
1627        let resolved = PathBuf::from(resolved.trim());
1628        if !resolved.is_absolute() {
1629            return Err(format!(
1630                "git returned a non-absolute core.hooksPath for {}: {}",
1631                project_root.display(),
1632                resolved.display()
1633            ));
1634        }
1635        return canonicalize_policy_path(resolved, "core.hooksPath");
1636    }
1637
1638    if configured.status.code() != Some(1) || !configured.stdout.is_empty() {
1639        return Err(format!(
1640            "git could not query core.hooksPath for {}: {}",
1641            project_root.display(),
1642            String::from_utf8_lossy(&configured.stderr).trim()
1643        ));
1644    }
1645    canonicalize_policy_path(common_dir.join("hooks"), "Git hooks")
1646}
1647
1648#[cfg(unix)]
1649fn canonicalize_policy_path(path: PathBuf, field: &str) -> Result<PathBuf, String> {
1650    match path.canonicalize() {
1651        Ok(path) => Ok(path),
1652        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1653            let mut ancestor = path.clone();
1654            let mut tail = Vec::new();
1655            loop {
1656                match ancestor.canonicalize() {
1657                    Ok(mut canonical) => {
1658                        for component in tail.iter().rev() {
1659                            canonical.push(component);
1660                        }
1661                        return Ok(canonical);
1662                    }
1663                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1664                        let component =
1665                            ancestor.file_name().map(ToOwned::to_owned).ok_or_else(|| {
1666                                format!(
1667                                    "failed to canonicalize {field} path {}: {error}",
1668                                    path.display()
1669                                )
1670                            })?;
1671                        tail.push(component);
1672                        if !ancestor.pop() {
1673                            return Err(format!(
1674                                "failed to canonicalize {field} path {}: {error}",
1675                                path.display()
1676                            ));
1677                        }
1678                    }
1679                    Err(error) => {
1680                        return Err(format!(
1681                            "failed to canonicalize {field} path {}: {error}",
1682                            path.display()
1683                        ));
1684                    }
1685                }
1686            }
1687        }
1688        Err(error) => Err(format!(
1689            "failed to canonicalize {field} path {}: {error}",
1690            path.display()
1691        )),
1692    }
1693}
1694
1695#[cfg(any(test, target_os = "linux"))]
1696#[derive(Debug, Clone)]
1697struct IntendedReadGrant {
1698    path: PathBuf,
1699    force_children: bool,
1700    mandatory: bool,
1701}
1702
1703#[cfg(any(test, target_os = "linux"))]
1704#[derive(Debug, Clone, PartialEq, Eq)]
1705struct ListedReadChild {
1706    path: PathBuf,
1707    is_dir: bool,
1708}
1709
1710#[cfg(any(test, target_os = "linux"))]
1711trait ReadDirectoryLister {
1712    fn children(&mut self, parent: &Path) -> Result<Vec<ListedReadChild>, String>;
1713}
1714
1715#[cfg(target_os = "linux")]
1716fn build_linux_read_allow(
1717    profile: &SandboxProfile,
1718    home: &Path,
1719    git_read_roots: &[PathBuf],
1720    omitted_roots: &[PathBuf],
1721) -> Result<Vec<PathBuf>, String> {
1722    let mandatory_floor = &profile.write_deny;
1723    validate_mandatory_floor_overlap(profile.write_allow_roots(), mandatory_floor)?;
1724
1725    let mut intended = Vec::new();
1726    for path in [
1727        "/usr",
1728        "/bin",
1729        "/sbin",
1730        "/lib",
1731        "/lib32",
1732        "/lib64",
1733        "/etc",
1734        "/opt",
1735        "/run",
1736        "/proc",
1737        "/sys/devices/system/cpu",
1738        "/sys/fs/cgroup",
1739        "/dev/null",
1740        "/dev/zero",
1741        "/dev/full",
1742        "/dev/random",
1743        "/dev/urandom",
1744        "/dev/tty",
1745        "/dev/ptmx",
1746        "/dev/pts",
1747        "/dev/fd",
1748        "/dev/stdin",
1749        "/dev/stdout",
1750        "/dev/stderr",
1751    ] {
1752        if let Some(path) = canonicalize_existing_static(Path::new(path))? {
1753            intended.push(IntendedReadGrant {
1754                path,
1755                force_children: false,
1756                mandatory: true,
1757            });
1758        }
1759    }
1760    if let Some(path) = canonicalize_existing_static(Path::new("/var"))? {
1761        intended.push(IntendedReadGrant {
1762            path,
1763            // Enumerating /var avoids following the /var/run symlink back into /run.
1764            force_children: true,
1765            mandatory: true,
1766        });
1767    }
1768
1769    intended.push(IntendedReadGrant {
1770        path: home.to_path_buf(),
1771        force_children: true,
1772        mandatory: false,
1773    });
1774    intended.extend(
1775        profile
1776            .write_allow_roots()
1777            .into_iter()
1778            .map(|path| IntendedReadGrant {
1779                path: path.to_path_buf(),
1780                force_children: false,
1781                mandatory: false,
1782            }),
1783    );
1784    intended.extend(
1785        git_read_roots
1786            .iter()
1787            .cloned()
1788            .map(|path| IntendedReadGrant {
1789                path,
1790                force_children: false,
1791                mandatory: false,
1792            }),
1793    );
1794
1795    let split_denies = profile
1796        .read_deny
1797        .iter()
1798        .chain(omitted_roots)
1799        .cloned()
1800        .collect::<Vec<_>>();
1801    let mut lister = SecureReadDirectoryLister;
1802    split_read_grants(&intended, &split_denies, &mut lister)
1803}
1804
1805#[cfg(target_os = "linux")]
1806fn add_linux_payload_read_grants(
1807    profile: &mut SandboxProfile,
1808    payload_grants: &[PathBuf],
1809) -> Result<(), String> {
1810    let intended = payload_grants
1811        .iter()
1812        .map(|path| {
1813            let path = path.canonicalize().map_err(|error| {
1814                format!(
1815                    "mandatory payload read path is unavailable: {}: {error}",
1816                    path.display()
1817                )
1818            })?;
1819            Ok(IntendedReadGrant {
1820                path,
1821                force_children: false,
1822                mandatory: true,
1823            })
1824        })
1825        .collect::<Result<Vec<_>, String>>()?;
1826    let mut lister = SecureReadDirectoryLister;
1827    let payload_grants = split_read_grants(&intended, &profile.read_deny, &mut lister)?;
1828
1829    let mut final_read_allow = profile.read_allow.clone();
1830    final_read_allow.extend(payload_grants);
1831    final_read_allow.sort_unstable();
1832    final_read_allow.dedup();
1833    validate_final_read_rules(&final_read_allow, &profile.read_deny)?;
1834    assert!(
1835        validate_final_read_rules(&final_read_allow, &profile.read_deny).is_ok(),
1836        "final Landlock read grants overlap a denied path after adding payload files"
1837    );
1838    profile.read_allow = final_read_allow;
1839    Ok(())
1840}
1841
1842#[cfg(target_os = "linux")]
1843fn canonicalize_existing_static(path: &Path) -> Result<Option<PathBuf>, String> {
1844    match std::fs::symlink_metadata(path) {
1845        Ok(_) => match path.canonicalize() {
1846            Ok(path) => Ok(Some(path)),
1847            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1848            Err(error) => Err(format!(
1849                "failed to canonicalize static read root {}: {error}",
1850                path.display()
1851            )),
1852        },
1853        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1854        Err(error) => Err(format!(
1855            "failed to inspect static read root {}: {error}",
1856            path.display()
1857        )),
1858    }
1859}
1860
1861#[cfg(any(test, target_os = "linux"))]
1862fn split_read_grants(
1863    intended: &[IntendedReadGrant],
1864    denies: &[PathBuf],
1865    lister: &mut impl ReadDirectoryLister,
1866) -> Result<Vec<PathBuf>, String> {
1867    let mut emitted = BTreeSet::new();
1868    for grant in intended {
1869        split_read_grant(grant, denies, lister, &mut emitted)?;
1870    }
1871    let emitted = emitted.into_iter().collect::<Vec<_>>();
1872    validate_final_read_rules(&emitted, denies)?;
1873    Ok(emitted)
1874}
1875
1876#[cfg(any(test, target_os = "linux"))]
1877fn split_read_grant(
1878    grant: &IntendedReadGrant,
1879    denies: &[PathBuf],
1880    lister: &mut impl ReadDirectoryLister,
1881    emitted: &mut BTreeSet<PathBuf>,
1882) -> Result<(), String> {
1883    if let Some(deny) = denies
1884        .iter()
1885        .find(|deny| grant.path == **deny || grant.path.starts_with(deny))
1886    {
1887        if grant.mandatory {
1888            return Err(format!(
1889                "sandbox_unavailable: mandatory read root {} is denied by {}",
1890                grant.path.display(),
1891                deny.display()
1892            ));
1893        }
1894        return Ok(());
1895    }
1896
1897    let contains_deny = denies.iter().any(|deny| deny.starts_with(&grant.path));
1898    if !grant.force_children && !contains_deny {
1899        emitted.insert(grant.path.clone());
1900        return Ok(());
1901    }
1902
1903    let children = lister.children(&grant.path).map_err(|error| {
1904        format!(
1905            "sandbox_unavailable: cannot split read root {}: {error}",
1906            grant.path.display()
1907        )
1908    })?;
1909    for child in children {
1910        let child_contains_deny = denies.iter().any(|deny| deny.starts_with(&child.path));
1911        if child_contains_deny && !child.is_dir {
1912            return Err(format!(
1913                "sandbox_unavailable: deny chain crosses non-directory path {}",
1914                child.path.display()
1915            ));
1916        }
1917        split_read_grant(
1918            &IntendedReadGrant {
1919                path: child.path,
1920                force_children: false,
1921                mandatory: false,
1922            },
1923            denies,
1924            lister,
1925            emitted,
1926        )?;
1927    }
1928    Ok(())
1929}
1930
1931#[cfg(any(test, target_os = "linux"))]
1932fn validate_mandatory_floor_overlap<'a>(
1933    writable_roots: impl IntoIterator<Item = &'a Path>,
1934    mandatory_floor: &[PathBuf],
1935) -> Result<(), String> {
1936    for writable in writable_roots {
1937        for secret in mandatory_floor {
1938            if paths_overlap(writable, secret) {
1939                return Err(format!(
1940                    "writable root {} overlaps mandatory secret floor {}",
1941                    writable.display(),
1942                    secret.display()
1943                ));
1944            }
1945        }
1946    }
1947    Ok(())
1948}
1949
1950#[cfg(any(test, target_os = "linux"))]
1951fn validate_final_read_rules(read_allow: &[PathBuf], denies: &[PathBuf]) -> Result<(), String> {
1952    for grant in read_allow {
1953        for deny in denies {
1954            if paths_overlap(grant, deny) {
1955                return Err(format!(
1956                    "sandbox_unavailable: final read grant {} overlaps denied path {}",
1957                    grant.display(),
1958                    deny.display()
1959                ));
1960            }
1961        }
1962    }
1963    Ok(())
1964}
1965
1966#[cfg(any(test, target_os = "linux"))]
1967fn paths_overlap(left: &Path, right: &Path) -> bool {
1968    left == right || left.starts_with(right) || right.starts_with(left)
1969}
1970
1971#[cfg(target_os = "linux")]
1972struct SecureReadDirectoryLister;
1973
1974#[cfg(target_os = "linux")]
1975impl ReadDirectoryLister for SecureReadDirectoryLister {
1976    fn children(&mut self, parent: &Path) -> Result<Vec<ListedReadChild>, String> {
1977        let parent_fd = open_absolute_no_symlinks(parent, true)?;
1978        let readable_fd =
1979            open_directory_for_enumeration(parent_fd.as_raw_fd()).map_err(|error| {
1980                format!(
1981                    "failed to open directory for enumeration {}: {error}",
1982                    parent.display()
1983                )
1984            })?;
1985        let duplicate = unsafe { libc::dup(readable_fd.as_raw_fd()) };
1986        if duplicate < 0 {
1987            return Err(format!(
1988                "failed to duplicate directory fd for {}: {}",
1989                parent.display(),
1990                std::io::Error::last_os_error()
1991            ));
1992        }
1993        let directory = unsafe { libc::fdopendir(duplicate) };
1994        if directory.is_null() {
1995            let error = std::io::Error::last_os_error();
1996            unsafe { libc::close(duplicate) };
1997            return Err(format!(
1998                "failed to enumerate directory {}: {error}",
1999                parent.display()
2000            ));
2001        }
2002
2003        let result = (|| {
2004            let mut children = Vec::new();
2005            loop {
2006                unsafe { *libc::__errno_location() = 0 };
2007                let entry = unsafe { libc::readdir(directory) };
2008                if entry.is_null() {
2009                    let error = std::io::Error::last_os_error();
2010                    if error.raw_os_error() == Some(0) {
2011                        break;
2012                    }
2013                    return Err(format!(
2014                        "failed while enumerating {}: {error}",
2015                        parent.display()
2016                    ));
2017                }
2018                let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
2019                if name == b"." || name == b".." {
2020                    continue;
2021                }
2022                let name = OsStr::from_bytes(name);
2023                let diagnostic_path = parent.join(name);
2024                let diagnostic = std::fs::symlink_metadata(&diagnostic_path).map_err(|error| {
2025                    format!(
2026                        "directory entry changed while inspecting {}: {error}",
2027                        diagnostic_path.display()
2028                    )
2029                })?;
2030                if diagnostic.file_type().is_symlink() {
2031                    continue;
2032                }
2033
2034                let child_fd =
2035                    open_child_no_symlinks(parent_fd.as_raw_fd(), name).map_err(|error| {
2036                        format!(
2037                            "directory entry changed while opening {}: {error}",
2038                            diagnostic_path.display()
2039                        )
2040                    })?;
2041                let metadata = fstat_fd(&child_fd).map_err(|error| {
2042                    format!(
2043                        "failed to inspect opened directory entry {}: {error}",
2044                        diagnostic_path.display()
2045                    )
2046                })?;
2047                if metadata.st_mode & libc::S_IFMT == libc::S_IFLNK {
2048                    continue;
2049                }
2050                children.push(ListedReadChild {
2051                    path: diagnostic_path,
2052                    is_dir: metadata.st_mode & libc::S_IFMT == libc::S_IFDIR,
2053                });
2054            }
2055            children.sort_unstable_by(|left, right| left.path.cmp(&right.path));
2056            Ok(children)
2057        })();
2058        unsafe { libc::closedir(directory) };
2059        result
2060    }
2061}
2062
2063#[cfg(target_os = "linux")]
2064#[repr(C)]
2065struct OpenHow {
2066    flags: u64,
2067    mode: u64,
2068    resolve: u64,
2069}
2070
2071#[cfg(target_os = "linux")]
2072const RESOLVE_NO_SYMLINKS: u64 = 0x04;
2073#[cfg(target_os = "linux")]
2074const RESOLVE_BENEATH: u64 = 0x08;
2075
2076#[cfg(target_os = "linux")]
2077fn open_absolute_no_symlinks(path: &Path, directory: bool) -> Result<OwnedFd, String> {
2078    if !path.is_absolute() {
2079        return Err(format!("path is not absolute: {}", path.display()));
2080    }
2081    let root = unsafe {
2082        libc::open(
2083            c"/".as_ptr(),
2084            libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
2085        )
2086    };
2087    if root < 0 {
2088        return Err(format!(
2089            "failed to open filesystem root: {}",
2090            std::io::Error::last_os_error()
2091        ));
2092    }
2093    let root = unsafe { OwnedFd::from_raw_fd(root) };
2094    let components = normalized_relative_components(path)?;
2095    if components.is_empty() {
2096        return Ok(root);
2097    }
2098
2099    let relative = components
2100        .iter()
2101        .fold(PathBuf::new(), |path, component| path.join(component));
2102    let relative = CString::new(relative.as_os_str().as_bytes())
2103        .map_err(|_| format!("path contains NUL: {}", path.display()))?;
2104    let flags = libc::O_PATH | libc::O_CLOEXEC | if directory { libc::O_DIRECTORY } else { 0 };
2105    let how = OpenHow {
2106        flags: flags as u64,
2107        mode: 0,
2108        resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
2109    };
2110    let opened = unsafe {
2111        libc::syscall(
2112            libc::SYS_openat2,
2113            root.as_raw_fd(),
2114            relative.as_ptr(),
2115            &how,
2116            std::mem::size_of::<OpenHow>(),
2117        ) as libc::c_int
2118    };
2119    if opened >= 0 {
2120        return Ok(unsafe { OwnedFd::from_raw_fd(opened) });
2121    }
2122    let error = std::io::Error::last_os_error();
2123    if error.raw_os_error() != Some(libc::ENOSYS) {
2124        return Err(format!(
2125            "secure open failed for {}: {error}",
2126            path.display()
2127        ));
2128    }
2129
2130    let mut current = root;
2131    for (index, component) in components.iter().enumerate() {
2132        let component = CString::new(component.as_bytes())
2133            .map_err(|_| format!("path contains NUL: {}", path.display()))?;
2134        let last = index + 1 == components.len();
2135        let mut flags = libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW;
2136        if !last || directory {
2137            flags |= libc::O_DIRECTORY;
2138        }
2139        let opened = unsafe { libc::openat(current.as_raw_fd(), component.as_ptr(), flags) };
2140        if opened < 0 {
2141            return Err(format!(
2142                "component-wise secure open failed for {}: {}",
2143                path.display(),
2144                std::io::Error::last_os_error()
2145            ));
2146        }
2147        let opened = unsafe { OwnedFd::from_raw_fd(opened) };
2148        let metadata = fstat_fd(&opened)
2149            .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?;
2150        if metadata.st_mode & libc::S_IFMT == libc::S_IFLNK {
2151            return Err(format!(
2152                "secure open encountered a symlink: {}",
2153                path.display()
2154            ));
2155        }
2156        current = opened;
2157    }
2158    Ok(current)
2159}
2160
2161#[cfg(target_os = "linux")]
2162fn open_directory_for_enumeration(parent_fd: i32) -> Result<OwnedFd, std::io::Error> {
2163    let how = OpenHow {
2164        flags: (libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) as u64,
2165        mode: 0,
2166        resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
2167    };
2168    let opened = unsafe {
2169        libc::syscall(
2170            libc::SYS_openat2,
2171            parent_fd,
2172            c".".as_ptr(),
2173            &how,
2174            std::mem::size_of::<OpenHow>(),
2175        ) as libc::c_int
2176    };
2177    if opened >= 0 {
2178        return Ok(unsafe { OwnedFd::from_raw_fd(opened) });
2179    }
2180    let error = std::io::Error::last_os_error();
2181    if error.raw_os_error() != Some(libc::ENOSYS) {
2182        return Err(error);
2183    }
2184
2185    let opened = unsafe {
2186        libc::openat(
2187            parent_fd,
2188            c".".as_ptr(),
2189            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2190        )
2191    };
2192    if opened < 0 {
2193        Err(std::io::Error::last_os_error())
2194    } else {
2195        Ok(unsafe { OwnedFd::from_raw_fd(opened) })
2196    }
2197}
2198
2199#[cfg(target_os = "linux")]
2200fn open_child_no_symlinks(parent_fd: i32, name: &OsStr) -> Result<OwnedFd, std::io::Error> {
2201    let name = CString::new(name.as_bytes())
2202        .map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))?;
2203    let how = OpenHow {
2204        flags: (libc::O_PATH | libc::O_CLOEXEC) as u64,
2205        mode: 0,
2206        resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
2207    };
2208    let opened = unsafe {
2209        libc::syscall(
2210            libc::SYS_openat2,
2211            parent_fd,
2212            name.as_ptr(),
2213            &how,
2214            std::mem::size_of::<OpenHow>(),
2215        ) as libc::c_int
2216    };
2217    if opened >= 0 {
2218        return Ok(unsafe { OwnedFd::from_raw_fd(opened) });
2219    }
2220    let error = std::io::Error::last_os_error();
2221    if error.raw_os_error() != Some(libc::ENOSYS) {
2222        return Err(error);
2223    }
2224
2225    let opened = unsafe {
2226        libc::openat(
2227            parent_fd,
2228            name.as_ptr(),
2229            libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2230        )
2231    };
2232    if opened < 0 {
2233        return Err(std::io::Error::last_os_error());
2234    }
2235    let opened = unsafe { OwnedFd::from_raw_fd(opened) };
2236    let metadata = fstat_fd(&opened)?;
2237    if metadata.st_mode & libc::S_IFMT == libc::S_IFLNK {
2238        return Err(std::io::Error::from_raw_os_error(libc::ELOOP));
2239    }
2240    Ok(opened)
2241}
2242
2243#[cfg(target_os = "linux")]
2244fn fstat_fd(fd: &OwnedFd) -> Result<libc::stat, std::io::Error> {
2245    let mut metadata = std::mem::MaybeUninit::<libc::stat>::uninit();
2246    if unsafe { libc::fstat(fd.as_raw_fd(), metadata.as_mut_ptr()) } < 0 {
2247        return Err(std::io::Error::last_os_error());
2248    }
2249    Ok(unsafe { metadata.assume_init() })
2250}
2251
2252#[cfg(target_os = "linux")]
2253fn normalized_relative_components(path: &Path) -> Result<Vec<&OsStr>, String> {
2254    let mut components = Vec::new();
2255    for component in path.components() {
2256        match component {
2257            std::path::Component::RootDir => {}
2258            std::path::Component::Normal(component) => components.push(component),
2259            _ => {
2260                return Err(format!(
2261                    "path is not normalized for secure open: {}",
2262                    path.display()
2263                ));
2264            }
2265        }
2266    }
2267    Ok(components)
2268}
2269
2270#[cfg(unix)]
2271fn expand_home(path: &Path, home: &Path) -> PathBuf {
2272    let mut components = path.components();
2273    if components
2274        .next()
2275        .is_some_and(|component| component.as_os_str() == "~")
2276    {
2277        return components.fold(home.to_path_buf(), |resolved, component| {
2278            resolved.join(component.as_os_str())
2279        });
2280    }
2281    path.to_path_buf()
2282}
2283
2284#[cfg(unix)]
2285fn create_task_temp_dir(task_bundle_dir: &Path) -> Result<PathBuf, String> {
2286    static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
2287    for _ in 0..32 {
2288        let nonce = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
2289        let path = task_bundle_dir.join(format!(".sandbox-tmp-{}-{nonce}", std::process::id()));
2290        match DirBuilder::new().mode(0o700).create(&path) {
2291            Ok(()) => {
2292                return path.canonicalize().map_err(|error| {
2293                    format!(
2294                        "failed to canonicalize task temp directory {}: {error}",
2295                        path.display()
2296                    )
2297                });
2298            }
2299            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2300            Err(error) => {
2301                return Err(format!(
2302                    "failed to create task temp directory {}: {error}",
2303                    path.display()
2304                ));
2305            }
2306        }
2307    }
2308    Err("failed to allocate a fresh task temp directory after 32 attempts".to_string())
2309}
2310
2311pub(crate) fn is_managed_task_temp_dir(path: &Path) -> bool {
2312    path.file_name()
2313        .and_then(OsStr::to_str)
2314        .is_some_and(|name| name.starts_with(".sandbox-tmp-"))
2315}
2316
2317fn note_test_observation(
2318    ctx: &AppContext,
2319    principal: &AuthenticatedPrincipal,
2320    requested_tier: RequestedSandboxTier,
2321    task_kind: SandboxTaskKind,
2322) {
2323    let Some(observations) = TEST_OBSERVATIONS.get() else {
2324        return;
2325    };
2326    let Some(project_root) = ctx.config().project_root.clone() else {
2327        return;
2328    };
2329    let project_root = observation_key(&project_root);
2330    if let Some(project) = observations
2331        .lock()
2332        .expect("sandbox spawn test observation mutex poisoned")
2333        .get_mut(&project_root)
2334    {
2335        project.push(SandboxSpawnObservation {
2336            principal: principal.clone(),
2337            requested_tier,
2338            task_kind,
2339        });
2340    }
2341}
2342
2343/// Start recording resolver calls for one project root.
2344#[doc(hidden)]
2345pub fn install_sandbox_spawn_test_seam(project_root: PathBuf) {
2346    TEST_OBSERVATIONS
2347        .get_or_init(|| Mutex::new(HashMap::new()))
2348        .lock()
2349        .expect("sandbox spawn test observation mutex poisoned")
2350        .insert(observation_key(&project_root), Vec::new());
2351}
2352
2353/// Snapshot resolver calls recorded for one project root.
2354#[doc(hidden)]
2355pub fn sandbox_spawn_test_observations(project_root: &Path) -> Vec<SandboxSpawnObservation> {
2356    TEST_OBSERVATIONS
2357        .get()
2358        .and_then(|observations| {
2359            observations
2360                .lock()
2361                .expect("sandbox spawn test observation mutex poisoned")
2362                .get(&observation_key(project_root))
2363                .cloned()
2364        })
2365        .unwrap_or_default()
2366}
2367
2368/// Remove one project-root resolver test seam.
2369#[doc(hidden)]
2370pub fn clear_sandbox_spawn_test_seam(project_root: &Path) {
2371    if let Some(observations) = TEST_OBSERVATIONS.get() {
2372        observations
2373            .lock()
2374            .expect("sandbox spawn test observation mutex poisoned")
2375            .remove(&observation_key(project_root));
2376    }
2377}
2378
2379fn observation_key(project_root: &Path) -> PathBuf {
2380    std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf())
2381}
2382
2383#[cfg(test)]
2384pub(crate) fn with_spawn_plan_for_test<R>(plan: SpawnPlan, run: impl FnOnce() -> R) -> R {
2385    struct PlanScope(Option<SpawnPlan>);
2386
2387    impl Drop for PlanScope {
2388        fn drop(&mut self) {
2389            TEST_PLAN_OVERRIDE.with(|slot| {
2390                slot.replace(self.0.take());
2391            });
2392        }
2393    }
2394
2395    let previous = TEST_PLAN_OVERRIDE.with(|slot| slot.replace(Some(plan)));
2396    let _scope = PlanScope(previous);
2397    run()
2398}
2399
2400/// Build a detached `Command` while enforcing the required launch plan.
2401///
2402/// Windows detached spawns route through the shell-candidate ladder, which
2403/// enforces the plan inline, so this helper is Unix-only.
2404#[cfg(unix)]
2405pub(crate) const CHILD_EXIT_FD: RawFd = 3;
2406#[cfg(unix)]
2407pub(crate) const CHILD_FAILURE_FD: RawFd = 4;
2408#[cfg(unix)]
2409pub(crate) const CHILD_PIPE_STATUS_FD: RawFd = 5;
2410
2411#[cfg(unix)]
2412pub(crate) fn apply_marker_fd_allowlist(
2413    command: &mut Command,
2414    exit_fd: RawFd,
2415    failure_fd: RawFd,
2416    pipeline_status_fd: Option<RawFd>,
2417) -> Result<(RawFd, RawFd), String> {
2418    use std::os::unix::process::CommandExt;
2419
2420    let fd_limit = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) };
2421    let fd_limit = if fd_limit > 0 {
2422        (fd_limit as RawFd).min(65_536)
2423    } else {
2424        1_024
2425    };
2426    unsafe {
2427        command.pre_exec(move || {
2428            let exit_copy = libc::fcntl(exit_fd, libc::F_DUPFD_CLOEXEC, 6);
2429            if exit_copy < 0 {
2430                return Err(std::io::Error::last_os_error());
2431            }
2432            let failure_copy = libc::fcntl(failure_fd, libc::F_DUPFD_CLOEXEC, 6);
2433            if failure_copy < 0 {
2434                let error = std::io::Error::last_os_error();
2435                libc::close(exit_copy);
2436                return Err(error);
2437            }
2438            let status_copy =
2439                pipeline_status_fd.map(|fd| libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 6));
2440            if status_copy.is_some_and(|fd| fd < 0) {
2441                let error = std::io::Error::last_os_error();
2442                libc::close(exit_copy);
2443                libc::close(failure_copy);
2444                return Err(error);
2445            }
2446            let status_copy = status_copy.unwrap_or(-1);
2447            if libc::dup2(exit_copy, CHILD_EXIT_FD) < 0
2448                || libc::dup2(failure_copy, CHILD_FAILURE_FD) < 0
2449                || (status_copy >= 0 && libc::dup2(status_copy, CHILD_PIPE_STATUS_FD) < 0)
2450            {
2451                let error = std::io::Error::last_os_error();
2452                libc::close(exit_copy);
2453                libc::close(failure_copy);
2454                if status_copy >= 0 {
2455                    libc::close(status_copy);
2456                }
2457                return Err(error);
2458            }
2459            libc::close(exit_copy);
2460            libc::close(failure_copy);
2461            if status_copy >= 0 {
2462                libc::close(status_copy);
2463            }
2464            let first_dynamic_fd = if pipeline_status_fd.is_some() {
2465                CHILD_PIPE_STATUS_FD + 1
2466            } else {
2467                CHILD_PIPE_STATUS_FD
2468            };
2469            for fd in first_dynamic_fd..fd_limit {
2470                let flags = libc::fcntl(fd, libc::F_GETFD);
2471                if flags >= 0 {
2472                    libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
2473                }
2474            }
2475            Ok(())
2476        });
2477    }
2478    Ok((CHILD_EXIT_FD, CHILD_FAILURE_FD))
2479}
2480
2481#[cfg(unix)]
2482pub(crate) fn detached_command_for_plan(
2483    plan: &SpawnPlan,
2484    program: &OsStr,
2485    args: &[OsString],
2486    task_marker: &Path,
2487    exit_fd: RawFd,
2488    failure_fd: RawFd,
2489) -> Result<(Command, Option<File>), String> {
2490    let (program, args, profile_handle) = command_argv_for_plan(
2491        plan,
2492        program,
2493        args,
2494        task_marker,
2495        Some((exit_fd, failure_fd)),
2496    )?;
2497    let mut command = crate::effective_path::new_command(program);
2498    command.args(args);
2499    crate::bash_background::process::start_new_session(&mut command);
2500    Ok((command, profile_handle))
2501}
2502
2503fn isolated_environment_for_plan(
2504    plan: &SpawnPlan,
2505    request_environment: &HashMap<String, String>,
2506) -> Option<ChildEnvironment> {
2507    #[cfg(unix)]
2508    if !matches!(plan.policy(), SpawnPlan::Unsandboxed) {
2509        if let Some(task) = plan.prepared_task() {
2510            return Some(task.environment().clone());
2511        }
2512    }
2513    match plan.policy() {
2514        SpawnPlan::Host { environment, .. } => Some(environment.clone()),
2515        SpawnPlan::Launcher { profile, .. } => Some(sandboxed_child_environment(
2516            request_environment,
2517            &profile.temp_dir,
2518        )),
2519        SpawnPlan::Unsandboxed | SpawnPlan::Refused { .. } => None,
2520        #[cfg(unix)]
2521        SpawnPlan::Prepared { .. } => unreachable!("policy() unwraps prepared plans"),
2522    }
2523}
2524
2525fn sandboxed_child_environment(
2526    request_environment: &HashMap<String, String>,
2527    temp_dir: &Path,
2528) -> ChildEnvironment {
2529    let mut environment = std::env::vars_os()
2530        .filter(|(key, _)| sandbox_base_environment_key(key))
2531        .collect::<ChildEnvironment>();
2532    // PATH must be AFT's enriched value rather than the daemon's original
2533    // value, which can omit package-manager and user tool locations.
2534    environment.insert(
2535        OsString::from("PATH"),
2536        crate::effective_path::effective_path().to_os_string(),
2537    );
2538    for (key, value) in request_environment {
2539        // A request-supplied environment reaches the OUTER launcher / `/bin/sh`
2540        // supervisor, which exec before Landlock/Seatbelt is installed. A
2541        // dynamic-loader or shell/interpreter startup hook here would execute
2542        // code OUTSIDE confinement, so those keys are dropped even though they
2543        // arrived through the (otherwise honored) request environment.
2544        if is_preexec_hijack_env_key(key.as_str())
2545            || crate::agent_child_env::is_subc_credential_env_key(key)
2546        {
2547            continue;
2548        }
2549        environment.insert(OsString::from(key), OsString::from(value));
2550    }
2551    for key in ["TMPDIR", "TEMP", "TMP"] {
2552        environment.insert(OsString::from(key), temp_dir.as_os_str().to_os_string());
2553    }
2554    environment
2555}
2556
2557fn sandbox_base_environment_key(key: &OsStr) -> bool {
2558    key.to_str().is_some_and(|key| {
2559        matches!(key, "HOME" | "USER" | "LOGNAME" | "SHELL" | "TERM" | "LANG")
2560            || key.starts_with("LC_")
2561    })
2562}
2563
2564/// Environment keys that make a process load a library or run code during its
2565/// own startup — before a native sandbox is installed in the launcher chain.
2566/// These are refused from the request environment regardless of value so an
2567/// injected `LD_PRELOAD` / `DYLD_INSERT_LIBRARIES` / `BASH_ENV` cannot execute
2568/// outside confinement. Matching is case-sensitive; POSIX environment names are
2569/// case-sensitive and the loaders only honor the exact upper-case spellings.
2570fn is_preexec_hijack_env_key(key: &str) -> bool {
2571    // Dynamic-loader families (glibc/musl `LD_*`, macdyld `DYLD_*`): the whole
2572    // prefix executes/loads at exec time, so block the family, not a fixed set.
2573    if key.starts_with("LD_") || key.starts_with("DYLD_") {
2574        return true;
2575    }
2576    matches!(
2577        key,
2578        // POSIX/bash shell startup + trace hooks (the supervisor is `/bin/sh`).
2579        "BASH_ENV"
2580            | "ENV"
2581            | "SHELLOPTS"
2582            | "BASHOPTS"
2583            | "PROMPT_COMMAND"
2584            | "PS4"
2585            | "IFS"
2586            // Interpreter auto-run / library-injection hooks.
2587            | "PYTHONSTARTUP"
2588            | "PYTHONPATH"
2589            | "PYTHONHOME"
2590            | "PERL5OPT"
2591            | "PERL5LIB"
2592            | "PERLLIB"
2593            | "PERL5DB"
2594            | "RUBYOPT"
2595            | "RUBYLIB"
2596            | "NODE_OPTIONS"
2597            // glibc auxiliary loader hooks.
2598            | "GCONV_PATH"
2599            | "LOCPATH"
2600            | "NLSPATH"
2601            | "HOSTALIASES"
2602            | "RESOLV_HOST_CONF"
2603    )
2604}
2605
2606#[cfg(unix)]
2607pub(crate) fn approved_environment_for_plan(
2608    plan: &SpawnPlan,
2609    request_environment: &HashMap<String, String>,
2610) -> ChildEnvironment {
2611    isolated_environment_for_plan(plan, request_environment)
2612        .unwrap_or_else(|| approved_payload_environment(request_environment, &std::env::temp_dir()))
2613}
2614
2615#[cfg(unix)]
2616pub(crate) fn apply_sandbox_environment(
2617    plan: &SpawnPlan,
2618    command: &mut Command,
2619    request_environment: &HashMap<String, String>,
2620) {
2621    if let Some(environment) = isolated_environment_for_plan(plan, request_environment) {
2622        // Host snapshots and native-launcher allowlists are complete child
2623        // environments. Clear the daemon environment first so loader hooks,
2624        // shell startup hooks, and cloud credentials cannot leak around them.
2625        command.env_clear().envs(environment);
2626    }
2627}
2628
2629/// Build a PTY `CommandBuilder` while enforcing the required launch plan.
2630pub(crate) fn pty_command_for_plan(
2631    plan: &SpawnPlan,
2632    program: &OsStr,
2633    args: &[OsString],
2634    task_marker: &Path,
2635    workdir: &Path,
2636    env: &HashMap<String, String>,
2637) -> Result<(CommandBuilder, Option<File>), String> {
2638    let (program, args, profile_handle) =
2639        command_argv_for_plan(plan, program, args, task_marker, None)?;
2640    let mut command = CommandBuilder::new(program);
2641    for arg in args {
2642        command.arg(arg);
2643    }
2644    command.cwd(workdir.as_os_str());
2645    if let Some(environment) = isolated_environment_for_plan(plan, env) {
2646        command.env_clear();
2647        for (key, value) in environment {
2648            command.env(key, value);
2649        }
2650    } else {
2651        // Sandbox-disabled PTYs retain the historical full inheritance and add
2652        // only request overrides.
2653        for (key, value) in env {
2654            command.env(key, value);
2655        }
2656    }
2657    crate::agent_child_env::scrub_pty_command(&mut command);
2658    Ok((command, profile_handle))
2659}
2660
2661fn command_argv_for_plan(
2662    plan: &SpawnPlan,
2663    program: &OsStr,
2664    args: &[OsString],
2665    task_marker: &Path,
2666    marker_fds: Option<(RawFd, RawFd)>,
2667) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2668    match plan.policy() {
2669        SpawnPlan::Unsandboxed | SpawnPlan::Host { .. } => {
2670            Ok((program.to_os_string(), args.to_vec(), None))
2671        }
2672        SpawnPlan::Refused { code, .. } => Err((*code).to_string()),
2673        SpawnPlan::Launcher {
2674            profile,
2675            launcher_path,
2676        } => {
2677            #[cfg(unix)]
2678            {
2679                launcher_argv(
2680                    profile,
2681                    launcher_path,
2682                    program,
2683                    args,
2684                    task_marker,
2685                    marker_fds,
2686                    plan.prepared_task(),
2687                )
2688            }
2689            #[cfg(not(unix))]
2690            {
2691                launcher_argv(
2692                    profile,
2693                    launcher_path,
2694                    program,
2695                    args,
2696                    task_marker,
2697                    marker_fds,
2698                    None,
2699                )
2700            }
2701        }
2702        #[cfg(unix)]
2703        SpawnPlan::Prepared { .. } => unreachable!("policy() unwraps prepared plans"),
2704    }
2705}
2706
2707#[cfg(unix)]
2708#[allow(clippy::too_many_arguments)]
2709fn launcher_argv(
2710    profile: &SandboxProfile,
2711    launcher_path: &Path,
2712    program: &OsStr,
2713    args: &[OsString],
2714    _task_marker: &Path,
2715    marker_fds: Option<(RawFd, RawFd)>,
2716    _prepared: Option<&PreparedTask>,
2717) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2718    let profile_json = serde_json::to_string(profile)
2719        .map_err(|error| format!("failed to serialize sandbox profile: {error}"))?;
2720    let Some((exit_fd, failure_fd)) = marker_fds else {
2721        let mut wrapped = vec![
2722            OsString::from("sandbox-launch"),
2723            OsString::from("--profile-json"),
2724            OsString::from(profile_json),
2725            OsString::from("--"),
2726            program.to_os_string(),
2727        ];
2728        wrapped.extend_from_slice(args);
2729        return Ok((launcher_path.as_os_str().to_os_string(), wrapped, None));
2730    };
2731
2732    let mut wrapped = vec![
2733        OsString::from("-c"),
2734        OsString::from(
2735            r#"launcher=$1
2736profile_json=$2
2737exit_fd=$3
2738failure_fd=$4
2739shift 4
2740"$launcher" sandbox-launch --profile-json "$profile_json" -- "$@"
2741code=$?
2742if [ "$code" -eq 78 ]; then
2743  printf "%s" sandbox_unavailable >&"$failure_fd"
2744  if [ ! -s "/dev/fd/$exit_fd" ]; then
2745    printf "%s" "$code" >&"$exit_fd"
2746  fi
2747fi
2748exit "$code""#,
2749        ),
2750        OsString::from("aft-sandbox-supervisor"),
2751        launcher_path.as_os_str().to_os_string(),
2752        OsString::from(profile_json),
2753        OsString::from(exit_fd.to_string()),
2754        OsString::from(failure_fd.to_string()),
2755        program.to_os_string(),
2756    ];
2757    wrapped.extend_from_slice(args);
2758    Ok((OsString::from("/bin/sh"), wrapped, None))
2759}
2760
2761#[cfg(not(unix))]
2762#[allow(clippy::too_many_arguments)]
2763fn launcher_argv(
2764    _profile: &SandboxProfile,
2765    _launcher_path: &Path,
2766    _program: &OsStr,
2767    _args: &[OsString],
2768    _task_marker: &Path,
2769    _marker_fds: Option<(i32, i32)>,
2770    _prepared: Option<&()>,
2771) -> Result<(OsString, Vec<OsString>, Option<File>), String> {
2772    Err("sandbox_unavailable".to_string())
2773}
2774
2775#[cfg(all(test, unix))]
2776mod tests {
2777
2778    use super::*;
2779
2780    #[test]
2781    fn host_plan_clears_inherited_environment_and_applies_snapshot() {
2782        let environment =
2783            ChildEnvironment::from([(OsString::from("APPROVED"), OsString::from("snapshot"))]);
2784        let plan = SpawnPlan::Host {
2785            shell_path: PathBuf::from("/bin/sh"),
2786            environment,
2787        };
2788        let mut command = Command::new("/bin/sh");
2789        command
2790            .arg("-c")
2791            .arg("test -z \"$SHOULD_DISAPPEAR\" && printf %s \"$APPROVED\"")
2792            .env("SHOULD_DISAPPEAR", "yes");
2793        apply_sandbox_environment(&plan, &mut command, &HashMap::new());
2794        let output = command.output().unwrap();
2795        assert!(output.status.success());
2796        assert_eq!(output.stdout, b"snapshot");
2797    }
2798
2799    #[test]
2800    fn launcher_plan_clears_ambient_environment_and_applies_safe_base() {
2801        let root = tempfile::tempdir().unwrap();
2802        let project = root.path().join("project");
2803        let temp = root.path().join("temp");
2804        std::fs::create_dir_all(&project).unwrap();
2805        std::fs::create_dir_all(&temp).unwrap();
2806        let profile = SandboxProfile::build(
2807            vec![project],
2808            Vec::new(),
2809            Vec::new(),
2810            Vec::new(),
2811            Vec::new(),
2812            Vec::new(),
2813            Vec::new(),
2814            temp,
2815        )
2816        .unwrap();
2817        let expected_temp = profile.temp_dir.clone();
2818        let plan = SpawnPlan::launcher_for_test(profile, PathBuf::from("/usr/bin/true"));
2819        let request_environment = HashMap::from([
2820            ("TERM".to_string(), "aft-test-term".to_string()),
2821            ("REQUEST_SENTINEL".to_string(), "request-value".to_string()),
2822        ]);
2823        let mut command = Command::new("/usr/bin/env");
2824        command
2825            .env("LD_PRELOAD", "/untrusted/loader.so")
2826            .env("DYLD_INSERT_LIBRARIES", "/untrusted/loader.dylib")
2827            .env("BASH_ENV", "/untrusted/bash-env")
2828            .env("AWS_SECRET_ACCESS_KEY", "ambient-secret");
2829
2830        apply_sandbox_environment(&plan, &mut command, &request_environment);
2831        let output = command.output().unwrap();
2832        assert!(output.status.success());
2833        let output = String::from_utf8(output.stdout).unwrap();
2834
2835        for leaked in [
2836            "LD_PRELOAD=",
2837            "DYLD_INSERT_LIBRARIES=",
2838            "BASH_ENV=",
2839            "AWS_SECRET_ACCESS_KEY=",
2840        ] {
2841            assert!(
2842                !output.contains(leaked),
2843                "ambient variable leaked: {leaked}"
2844            );
2845        }
2846        assert!(output.contains("REQUEST_SENTINEL=request-value\n"));
2847        assert!(output.contains("TERM=aft-test-term\n"));
2848        assert!(output.contains(&format!(
2849            "PATH={}\n",
2850            crate::effective_path::effective_path().to_string_lossy()
2851        )));
2852        if let Some(home) = std::env::var_os("HOME") {
2853            assert!(output.contains(&format!("HOME={}\n", home.to_string_lossy())));
2854        }
2855        for key in ["TMPDIR", "TEMP", "TMP"] {
2856            assert!(output.contains(&format!("{key}={}\n", expected_temp.display())));
2857        }
2858    }
2859
2860    #[cfg(unix)]
2861    #[test]
2862    fn request_environment_cannot_inject_preexec_hijack_variables() {
2863        // The pre-sandbox boundary: a request-supplied environment is applied to
2864        // the OUTER launcher / `/bin/sh` supervisor, which run BEFORE Landlock or
2865        // Seatbelt is installed. A loader/shell/interpreter startup hook arriving
2866        // through the request env must be dropped, or injected code executes
2867        // outside confinement. (The sibling test covers the ambient direction;
2868        // this covers the request-supplied direction the re-audit flagged.)
2869        let project = tempfile::tempdir().unwrap();
2870        let temp = project.path().join("sandbox-temp");
2871        std::fs::create_dir(&temp).unwrap();
2872        let profile = crate::sandbox_profile::SandboxProfile::build(
2873            vec![project.path().to_path_buf()],
2874            Vec::new(),
2875            Vec::new(),
2876            Vec::new(),
2877            Vec::new(),
2878            Vec::new(),
2879            Vec::new(),
2880            temp,
2881        )
2882        .unwrap();
2883        let plan = SpawnPlan::launcher_for_test(profile, PathBuf::from("/usr/bin/true"));
2884
2885        // Every dangerous key arrives through the REQUEST environment this time.
2886        let hijacks = [
2887            ("LD_PRELOAD", "/untrusted/loader.so"),
2888            ("LD_LIBRARY_PATH", "/untrusted/lib"),
2889            ("LD_AUDIT", "/untrusted/audit.so"),
2890            ("DYLD_INSERT_LIBRARIES", "/untrusted/loader.dylib"),
2891            ("DYLD_LIBRARY_PATH", "/untrusted/dylib"),
2892            ("BASH_ENV", "/untrusted/bash-env"),
2893            ("ENV", "/untrusted/sh-env"),
2894            ("PROMPT_COMMAND", "/untrusted/cmd"),
2895            ("PS4", "evil"),
2896            ("PYTHONSTARTUP", "/untrusted/py"),
2897            ("PYTHONPATH", "/untrusted/pypath"),
2898            ("PERL5OPT", "-M/untrusted"),
2899            ("RUBYOPT", "-r/untrusted"),
2900            ("NODE_OPTIONS", "--require=/untrusted"),
2901            ("GCONV_PATH", "/untrusted/gconv"),
2902            ("LOCPATH", "/untrusted/loc"),
2903        ];
2904        let request_environment: HashMap<String, String> = hijacks
2905            .iter()
2906            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
2907            .chain([("REQUEST_SENTINEL".to_string(), "kept".to_string())])
2908            .collect();
2909
2910        let mut command = Command::new("/usr/bin/env");
2911        apply_sandbox_environment(&plan, &mut command, &request_environment);
2912        let output = command.output().unwrap();
2913        assert!(output.status.success());
2914        let output = String::from_utf8(output.stdout).unwrap();
2915
2916        for (key, _) in hijacks {
2917            assert!(
2918                !output.contains(&format!("{key}=")),
2919                "request-supplied hijack var reached the pre-sandbox child: {key}\n{output}"
2920            );
2921        }
2922        // A benign request var still flows through, proving we filtered rather
2923        // than dropped the whole request environment.
2924        assert!(output.contains("REQUEST_SENTINEL=kept\n"), "{output}");
2925    }
2926
2927    #[test]
2928    fn request_environment_cannot_smuggle_subc_credentials_into_sandbox_launcher() {
2929        let temp = tempfile::tempdir().unwrap();
2930        let request_environment = HashMap::from([
2931            ("SUBC_MODULE_ID".to_string(), "aft".to_string()),
2932            ("SUBC_LAUNCH_NONCE".to_string(), "nonce".to_string()),
2933            (
2934                "SUBC_FUTURE_CREDENTIAL".to_string(),
2935                "future-secret".to_string(),
2936            ),
2937            ("REQUEST_SENTINEL".to_string(), "kept".to_string()),
2938        ]);
2939
2940        let environment = sandboxed_child_environment(&request_environment, temp.path());
2941
2942        assert!(environment
2943            .keys()
2944            .filter_map(|key| key.to_str())
2945            .all(|key| !crate::agent_child_env::is_subc_credential_env_key(key)));
2946        assert_eq!(
2947            environment.get(OsStr::new("REQUEST_SENTINEL")),
2948            Some(&OsString::from("kept"))
2949        );
2950    }
2951
2952    #[test]
2953    fn unsandboxed_plan_preserves_inherited_and_request_environment() {
2954        let plan = SpawnPlan::Unsandboxed;
2955        let request_environment =
2956            HashMap::from([("REQUEST_SENTINEL".to_string(), "request-value".to_string())]);
2957        #[cfg(target_os = "macos")]
2958        let loader_hook = "/usr/lib/libSystem.B.dylib";
2959        #[cfg(target_os = "linux")]
2960        let loader_hook = "libc.so.6";
2961        let mut command = Command::new("/usr/bin/env");
2962        command
2963            .env("UNSANDBOXED_PARENT_SENTINEL", "raw-parent")
2964            .env("LD_PRELOAD", loader_hook)
2965            .env("DYLD_INSERT_LIBRARIES", loader_hook)
2966            .env("AWS_SECRET_ACCESS_KEY", "ambient-cloud-secret")
2967            .envs(&request_environment);
2968
2969        let before = command
2970            .get_envs()
2971            .map(|(key, value)| (key.to_os_string(), value.map(OsStr::to_os_string)))
2972            .collect::<Vec<_>>();
2973        apply_sandbox_environment(&plan, &mut command, &request_environment);
2974        let after = command
2975            .get_envs()
2976            .map(|(key, value)| (key.to_os_string(), value.map(OsStr::to_os_string)))
2977            .collect::<Vec<_>>();
2978        assert_eq!(after, before, "unsandboxed environment overrides changed");
2979        let output = command.output().unwrap();
2980        assert!(output.status.success());
2981        let output = String::from_utf8(output.stdout).unwrap();
2982        assert!(output.contains("UNSANDBOXED_PARENT_SENTINEL=raw-parent\n"));
2983        assert!(output.contains(&format!("LD_PRELOAD={loader_hook}\n")));
2984        #[cfg(target_os = "linux")]
2985        assert!(output.contains(&format!("DYLD_INSERT_LIBRARIES={loader_hook}\n")));
2986        assert!(output.contains("AWS_SECRET_ACCESS_KEY=ambient-cloud-secret\n"));
2987        assert!(output.contains("REQUEST_SENTINEL=request-value\n"));
2988    }
2989
2990    #[test]
2991    fn product_profile_is_passed_as_a_verified_buffer() {
2992        let root = tempfile::tempdir().unwrap();
2993        let project = root.path().join("project");
2994        let temp = root.path().join("temp");
2995        std::fs::create_dir_all(&project).unwrap();
2996        std::fs::create_dir_all(&temp).unwrap();
2997        let profile = SandboxProfile::build(
2998            vec![project],
2999            Vec::new(),
3000            Vec::new(),
3001            Vec::new(),
3002            Vec::new(),
3003            Vec::new(),
3004            Vec::new(),
3005            temp,
3006        )
3007        .unwrap();
3008        let (_program, args, retained) = launcher_argv(
3009            &profile,
3010            Path::new("/bin/aft"),
3011            OsStr::new("/bin/sh"),
3012            &[OsString::from("-c"), OsString::from("true")],
3013            Path::new("unused"),
3014            None,
3015            None,
3016        )
3017        .unwrap();
3018        assert_eq!(args[0], "sandbox-launch");
3019        assert_eq!(args[1], "--profile-json");
3020        assert!(serde_json::from_str::<SandboxProfile>(args[2].to_str().unwrap()).is_ok());
3021        assert!(retained.is_none());
3022        assert!(!root.path().join("sandbox-profile.json").exists());
3023    }
3024}
3025
3026#[cfg(test)]
3027mod policy_tests {
3028    use super::*;
3029
3030    fn context(project_root: PathBuf) -> AppContext {
3031        AppContext::new(
3032            Box::new(crate::parser::TreeSitterProvider::new()),
3033            crate::config::Config {
3034                project_root: Some(project_root),
3035                sandbox: crate::config::SandboxConfig {
3036                    enabled: true,
3037                    ..crate::config::SandboxConfig::default()
3038                },
3039                ..crate::config::Config::default()
3040            },
3041        )
3042    }
3043
3044    #[cfg(target_os = "macos")]
3045    #[test]
3046    fn concurrent_sandbox_marker_pty_and_git_spawns_preserve_fd_ownership() {
3047        use portable_pty::{CommandBuilder, PtySize};
3048        use std::fs::OpenOptions;
3049        use std::os::fd::AsRawFd;
3050        use std::os::unix::fs::OpenOptionsExt;
3051        use std::process::Stdio;
3052        use std::sync::{Arc, Barrier};
3053
3054        const ITERATIONS: usize = 32;
3055        let fixture = tempfile::tempdir().unwrap();
3056        let project = fixture.path().join("project");
3057        let task_io = fixture
3058            .path()
3059            .join("tasks/session/bash-0000000000000001/io");
3060        std::fs::create_dir_all(&project).unwrap();
3061        std::fs::create_dir_all(&task_io).unwrap();
3062        let initialized = Command::new("git")
3063            .args(["init", "--quiet"])
3064            .current_dir(&project)
3065            .status()
3066            .unwrap();
3067        assert!(initialized.success());
3068
3069        let ctx = Arc::new(context(project));
3070        let task_io = Arc::new(task_io);
3071        let barrier = Arc::new(Barrier::new(4));
3072
3073        let resolver = {
3074            let ctx = Arc::clone(&ctx);
3075            let task_io = Arc::clone(&task_io);
3076            let barrier = Arc::clone(&barrier);
3077            std::thread::spawn(move || {
3078                barrier.wait();
3079                for _ in 0..ITERATIONS {
3080                    let plan = resolve_sandbox_spawn(
3081                        &ctx,
3082                        &AuthenticatedPrincipal::FirstParty,
3083                        RequestedSandboxTier::Native,
3084                        SandboxTaskKind::BashBackground,
3085                        &task_io,
3086                        None,
3087                    );
3088                    assert!(plan.is_native_launcher(), "unexpected plan: {plan:?}");
3089                    plan.cleanup_unspawned();
3090                }
3091            })
3092        };
3093        let detached = {
3094            let barrier = Arc::clone(&barrier);
3095            std::thread::spawn(move || {
3096                barrier.wait();
3097                for _ in 0..ITERATIONS {
3098                    let exit = tempfile::tempfile().unwrap();
3099                    let failure = tempfile::tempfile().unwrap();
3100                    crate::bash_background::persistence::set_close_on_exec(exit.as_raw_fd(), false)
3101                        .unwrap();
3102                    crate::bash_background::persistence::set_close_on_exec(
3103                        failure.as_raw_fd(),
3104                        false,
3105                    )
3106                    .unwrap();
3107                    let mut command = Command::new("/usr/bin/true");
3108                    apply_marker_fd_allowlist(
3109                        &mut command,
3110                        exit.as_raw_fd(),
3111                        failure.as_raw_fd(),
3112                        None,
3113                    )
3114                    .unwrap();
3115                    let status = command
3116                        .stdin(Stdio::null())
3117                        .stdout(Stdio::null())
3118                        .stderr(Stdio::null())
3119                        .status()
3120                        .unwrap();
3121                    assert!(status.success());
3122                }
3123            })
3124        };
3125        let pty = {
3126            let barrier = Arc::clone(&barrier);
3127            std::thread::spawn(move || {
3128                barrier.wait();
3129                for _ in 0..ITERATIONS {
3130                    let pair = portable_pty::native_pty_system()
3131                        .openpty(PtySize {
3132                            rows: 24,
3133                            cols: 80,
3134                            pixel_width: 0,
3135                            pixel_height: 0,
3136                        })
3137                        .unwrap();
3138                    let mut child = pair
3139                        .slave
3140                        .spawn_command(CommandBuilder::new("/usr/bin/true"))
3141                        .unwrap();
3142                    child.wait().unwrap();
3143                }
3144            })
3145        };
3146        let probes = {
3147            let barrier = Arc::clone(&barrier);
3148            std::thread::spawn(move || {
3149                barrier.wait();
3150                for _ in 0..ITERATIONS {
3151                    let output = Command::new("git").arg("--version").output().unwrap();
3152                    assert!(output.status.success());
3153                    let file = OpenOptions::new()
3154                        .read(true)
3155                        .write(true)
3156                        .custom_flags(libc::O_CLOEXEC)
3157                        .open("/dev/null")
3158                        .unwrap();
3159                    drop(file);
3160                }
3161            })
3162        };
3163
3164        resolver.join().unwrap();
3165        detached.join().unwrap();
3166        pty.join().unwrap();
3167        probes.join().unwrap();
3168    }
3169
3170    #[cfg(unix)]
3171    #[test]
3172    fn sandbox_setup_refusal_names_root_and_cause_before_remedy() {
3173        let project = tempfile::tempdir().unwrap();
3174        let ctx = context(project.path().to_path_buf());
3175        let plan = crate::log_ctx::with_session(Some("fd-audit".to_string()), || {
3176            sandbox_setup_refusal(
3177                &ctx,
3178                &AuthenticatedPrincipal::FirstParty,
3179                "failed to query core.hooksPath: Bad file descriptor (os error 9)",
3180            )
3181        });
3182        let message = plan.refusal_message().expect("sandbox refusal message");
3183        assert!(message.starts_with(&format!(
3184            "sandbox setup for {} failed: failed to query core.hooksPath: Bad file descriptor (os error 9)",
3185            project.path().display()
3186        )));
3187        assert!(message.ends_with("set sandbox.enabled=false to disable native sandboxing"));
3188    }
3189
3190    #[cfg(unix)]
3191    #[test]
3192    fn native_sandbox_predicate_controls_spawn_and_rewrite() {
3193        let project = tempfile::tempdir().unwrap();
3194        let file = project.path().join("rewrite-probe.txt");
3195        std::fs::write(&file, "sandboxed\n").unwrap();
3196        let ctx = context(project.path().to_path_buf());
3197        ctx.update_config(|config| config.experimental_bash_rewrite = true);
3198        let principal = AuthenticatedPrincipal::FirstParty;
3199        let command = format!("cat {}", file.display());
3200
3201        assert!(native_sandbox_enforced(&ctx, &principal));
3202        assert!(crate::bash_rewrite::try_rewrite(&command, None, &ctx, &principal).is_none());
3203        let sandboxed = resolve_sandbox_spawn(
3204            &ctx,
3205            &principal,
3206            RequestedSandboxTier::Native,
3207            SandboxTaskKind::BashForeground,
3208            project.path(),
3209            None,
3210        );
3211        assert!(matches!(&sandboxed, SpawnPlan::Launcher { .. }));
3212        sandboxed.cleanup_unspawned();
3213
3214        ctx.update_config(|config| config.sandbox.enabled = false);
3215        assert!(!native_sandbox_enforced(&ctx, &principal));
3216        assert!(crate::bash_rewrite::try_rewrite(&command, None, &ctx, &principal).is_some());
3217        assert_eq!(
3218            resolve_sandbox_spawn(
3219                &ctx,
3220                &principal,
3221                RequestedSandboxTier::Native,
3222                SandboxTaskKind::BashForeground,
3223                project.path(),
3224                None,
3225            ),
3226            SpawnPlan::Unsandboxed
3227        );
3228    }
3229
3230    #[test]
3231    fn untrusted_principal_never_enters_the_native_launcher() {
3232        let project = tempfile::tempdir().unwrap();
3233        let ctx = context(project.path().to_path_buf());
3234        let principal = AuthenticatedPrincipal::RouteBind {
3235            trust: PrincipalTrust::Untrusted,
3236            route_channel: 7,
3237            route_epoch: 1,
3238            project_root: project.path().to_path_buf(),
3239            harness: "mcp:test".to_string(),
3240            session_id: "untrusted-sandbox-test".to_string(),
3241            principal_id: Some("unverified".to_string()),
3242        };
3243
3244        let plan = resolve_sandbox_spawn(
3245            &ctx,
3246            &principal,
3247            RequestedSandboxTier::Native,
3248            SandboxTaskKind::BashForeground,
3249            project.path(),
3250            None,
3251        );
3252        // The invariant is that an untrusted principal never receives a native
3253        // Launcher plan. On Unix that surfaces as a downgrade to Unsandboxed
3254        // (the untrusted principal fails the first-party enforcement check); on
3255        // platforms without a kernel backend the enabled policy fails closed
3256        // with a platform refusal before the trust check is reached. Both honor
3257        // the invariant, so assert the exact non-Launcher outcome per platform.
3258        #[cfg(unix)]
3259        assert_eq!(plan, SpawnPlan::Unsandboxed);
3260        #[cfg(not(unix))]
3261        assert!(
3262            matches!(&plan, SpawnPlan::Refused { code, .. } if *code == "sandbox_unavailable"),
3263            "untrusted principal must never reach the native launcher; got {plan:?}"
3264        );
3265    }
3266
3267    #[cfg(unix)]
3268    fn grant_attempt(
3269        grant_id: String,
3270        project: &Path,
3271        command: &[u8],
3272        environment: ChildEnvironment,
3273    ) -> HostEscalationAttempt {
3274        HostEscalationAttempt {
3275            grant_id,
3276            command: command.to_vec(),
3277            root: project.to_path_buf(),
3278            cwd: project.to_path_buf(),
3279            shell_path: PathBuf::from("/bin/sh"),
3280            environment,
3281        }
3282    }
3283
3284    #[cfg(unix)]
3285    fn mint_test_grant(
3286        ctx: &AppContext,
3287        principal: &AuthenticatedPrincipal,
3288        project: &Path,
3289        command: &[u8],
3290        environment: &ChildEnvironment,
3291        now: Instant,
3292    ) -> String {
3293        mint_host_escalation_grant_at(
3294            ctx,
3295            principal,
3296            command,
3297            project,
3298            project,
3299            Path::new("/bin/sh"),
3300            environment,
3301            &project.join(".aft-test-storage"),
3302            "test-session",
3303            now,
3304        )
3305        .unwrap()
3306    }
3307
3308    #[cfg(unix)]
3309    fn refusal_class(plan: &SpawnPlan) -> Option<&'static str> {
3310        plan.refusal_mismatch_class()
3311    }
3312
3313    #[cfg(unix)]
3314    #[test]
3315    fn escalation_grant_binds_exact_command_and_environment() {
3316        let project = tempfile::tempdir().unwrap();
3317        let ctx = context(project.path().to_path_buf());
3318        let principal = AuthenticatedPrincipal::FirstParty;
3319        let environment = ChildEnvironment::from([
3320            (OsString::from("A"), OsString::from("one")),
3321            (OsString::from("B"), OsString::from("two")),
3322        ]);
3323
3324        for (command, retry_environment) in [
3325            (b"printf approved!".as_slice(), environment.clone()),
3326            (
3327                b"printf approved".as_slice(),
3328                ChildEnvironment::from([
3329                    (OsString::from("A"), OsString::from("changed")),
3330                    (OsString::from("B"), OsString::from("two")),
3331                ]),
3332            ),
3333        ] {
3334            let grant_id = mint_test_grant(
3335                &ctx,
3336                &principal,
3337                project.path(),
3338                b"printf approved",
3339                &environment,
3340                Instant::now(),
3341            );
3342            let attempt = grant_attempt(grant_id, project.path(), command, retry_environment);
3343            let plan = resolve_sandbox_spawn(
3344                &ctx,
3345                &principal,
3346                RequestedSandboxTier::Host,
3347                SandboxTaskKind::BashForeground,
3348                project.path(),
3349                Some(&attempt),
3350            );
3351            assert_eq!(refusal_class(&plan), Some("digest_mismatch"));
3352        }
3353    }
3354
3355    #[cfg(unix)]
3356    #[test]
3357    fn escalation_grant_is_single_use_and_expires() {
3358        let project = tempfile::tempdir().unwrap();
3359        let ctx = context(project.path().to_path_buf());
3360        let principal = AuthenticatedPrincipal::FirstParty;
3361        let environment =
3362            ChildEnvironment::from([(OsString::from("ONLY"), OsString::from("snapshot"))]);
3363        let grant_id = mint_test_grant(
3364            &ctx,
3365            &principal,
3366            project.path(),
3367            b"true",
3368            &environment,
3369            Instant::now(),
3370        );
3371        let attempt = grant_attempt(grant_id, project.path(), b"true", environment.clone());
3372        let first = resolve_sandbox_spawn(
3373            &ctx,
3374            &principal,
3375            RequestedSandboxTier::Host,
3376            SandboxTaskKind::BashForeground,
3377            project.path(),
3378            Some(&attempt),
3379        );
3380        assert!(matches!(first.policy(), SpawnPlan::Host { .. }));
3381        let second = resolve_sandbox_spawn(
3382            &ctx,
3383            &principal,
3384            RequestedSandboxTier::Host,
3385            SandboxTaskKind::BashForeground,
3386            project.path(),
3387            Some(&attempt),
3388        );
3389        assert_eq!(refusal_class(&second), Some("consumed"));
3390
3391        let expired_id = mint_test_grant(
3392            &ctx,
3393            &principal,
3394            project.path(),
3395            b"true",
3396            &environment,
3397            Instant::now() - ESCALATION_GRANT_TTL - Duration::from_millis(1),
3398        );
3399        let expired_attempt =
3400            grant_attempt(expired_id, project.path(), b"true", environment.clone());
3401        let expired = resolve_sandbox_spawn(
3402            &ctx,
3403            &principal,
3404            RequestedSandboxTier::Host,
3405            SandboxTaskKind::BashForeground,
3406            project.path(),
3407            Some(&expired_attempt),
3408        );
3409        assert_eq!(refusal_class(&expired), Some("expired"));
3410    }
3411
3412    #[cfg(unix)]
3413    #[test]
3414    fn escalation_grant_binds_principal_and_happy_path_reuses_snapshot() {
3415        let project = tempfile::tempdir().unwrap();
3416        let ctx = context(project.path().to_path_buf());
3417        let principal = AuthenticatedPrincipal::RouteBind {
3418            trust: PrincipalTrust::FirstParty,
3419            route_channel: 9,
3420            route_epoch: 2,
3421            project_root: project.path().to_path_buf(),
3422            harness: "opencode".to_string(),
3423            session_id: "session-x".to_string(),
3424            principal_id: Some("direct".to_string()),
3425        };
3426        let environment =
3427            ChildEnvironment::from([(OsString::from("APPROVED"), OsString::from("snapshot"))]);
3428        let wrong_id = mint_test_grant(
3429            &ctx,
3430            &principal,
3431            project.path(),
3432            b"true",
3433            &environment,
3434            Instant::now(),
3435        );
3436        let wrong_attempt = grant_attempt(wrong_id, project.path(), b"true", environment.clone());
3437        let wrong = resolve_sandbox_spawn(
3438            &ctx,
3439            &AuthenticatedPrincipal::FirstParty,
3440            RequestedSandboxTier::Host,
3441            SandboxTaskKind::BashForeground,
3442            project.path(),
3443            Some(&wrong_attempt),
3444        );
3445        assert_eq!(refusal_class(&wrong), Some("wrong_principal"));
3446
3447        let happy_id = mint_test_grant(
3448            &ctx,
3449            &principal,
3450            project.path(),
3451            b"true",
3452            &environment,
3453            Instant::now(),
3454        );
3455        let happy_attempt = grant_attempt(happy_id, project.path(), b"true", environment.clone());
3456        let happy = resolve_sandbox_spawn(
3457            &ctx,
3458            &principal,
3459            RequestedSandboxTier::Host,
3460            SandboxTaskKind::BashForeground,
3461            project.path(),
3462            Some(&happy_attempt),
3463        );
3464        match happy.policy() {
3465            SpawnPlan::Host {
3466                shell_path,
3467                environment: actual,
3468            } => {
3469                assert_eq!(shell_path, Path::new("/bin/sh"));
3470                assert_eq!(actual, &environment);
3471            }
3472            other => panic!("expected host plan, got {other:?}"),
3473        }
3474    }
3475
3476    #[cfg(unix)]
3477    #[test]
3478    fn untrusted_host_request_refuses_without_minting_a_grant() {
3479        let project = tempfile::tempdir().unwrap();
3480        let ctx = context(project.path().to_path_buf());
3481        let principal = AuthenticatedPrincipal::RouteBind {
3482            trust: PrincipalTrust::Untrusted,
3483            route_channel: 7,
3484            route_epoch: 1,
3485            project_root: project.path().to_path_buf(),
3486            harness: "mcp:test".to_string(),
3487            session_id: "untrusted-escalation-test".to_string(),
3488            principal_id: Some("unverified".to_string()),
3489        };
3490        let plan = resolve_sandbox_spawn(
3491            &ctx,
3492            &principal,
3493            RequestedSandboxTier::Host,
3494            SandboxTaskKind::BashForeground,
3495            project.path(),
3496            None,
3497        );
3498        assert_eq!(plan.refusal_code(), Some("sandbox_escalation_denied"));
3499        assert!(ctx.escalation_grants().lock().grants.is_empty());
3500    }
3501
3502    #[cfg(unix)]
3503    fn grant_task_paths(ctx: &AppContext, grant_id: &str) -> (PathBuf, String) {
3504        let store = ctx.escalation_grants().lock();
3505        let grant = store.grants.get(grant_id).unwrap();
3506        (grant.session_dir.clone(), grant.task_id.clone())
3507    }
3508
3509    #[cfg(unix)]
3510    #[test]
3511    fn escalated_payload_path_race_is_refused_and_burns_the_grant() {
3512        use std::os::unix::fs::symlink;
3513
3514        let project = tempfile::tempdir().unwrap();
3515        let ctx = context(project.path().to_path_buf());
3516        let principal = AuthenticatedPrincipal::FirstParty;
3517        let environment = ChildEnvironment::new();
3518        let grant_id = mint_test_grant(
3519            &ctx,
3520            &principal,
3521            project.path(),
3522            b"true",
3523            &environment,
3524            Instant::now(),
3525        );
3526        let (session_dir, task_id) = grant_task_paths(&ctx, &grant_id);
3527        let command_path = session_dir
3528            .join(&task_id)
3529            .join("control")
3530            .join(crate::bash_background::persistence::COMMAND_FILE);
3531        let victim = project.path().join("victim");
3532        std::fs::write(&victim, b"victim-bytes").unwrap();
3533        std::fs::remove_file(&command_path).unwrap();
3534        symlink(&victim, &command_path).unwrap();
3535
3536        let attempt = grant_attempt(grant_id.clone(), project.path(), b"true", environment);
3537        let refused = resolve_sandbox_spawn(
3538            &ctx,
3539            &principal,
3540            RequestedSandboxTier::Host,
3541            SandboxTaskKind::BashForeground,
3542            project.path(),
3543            Some(&attempt),
3544        );
3545        assert_eq!(refusal_class(&refused), Some("digest_mismatch"));
3546        assert_eq!(std::fs::read(&victim).unwrap(), b"victim-bytes");
3547        let consumed = resolve_sandbox_spawn(
3548            &ctx,
3549            &principal,
3550            RequestedSandboxTier::Host,
3551            SandboxTaskKind::BashForeground,
3552            project.path(),
3553            Some(&attempt),
3554        );
3555        assert_eq!(refusal_class(&consumed), Some("consumed"));
3556    }
3557
3558    #[cfg(unix)]
3559    #[test]
3560    fn verified_host_payload_executes_verified_buffers_after_inode_mutation() {
3561        use std::fs::OpenOptions;
3562        use std::os::fd::AsRawFd;
3563        use std::os::unix::fs::OpenOptionsExt;
3564
3565        let project = tempfile::tempdir().unwrap();
3566        let ctx = context(project.path().to_path_buf());
3567        let principal = AuthenticatedPrincipal::FirstParty;
3568        let environment = ChildEnvironment::new();
3569        let grant_id = mint_test_grant(
3570            &ctx,
3571            &principal,
3572            project.path(),
3573            b"true",
3574            &environment,
3575            Instant::now(),
3576        );
3577        let attempt = grant_attempt(grant_id, project.path(), b"true", environment);
3578        let plan = resolve_sandbox_spawn(
3579            &ctx,
3580            &principal,
3581            RequestedSandboxTier::Host,
3582            SandboxTaskKind::BashForeground,
3583            project.path(),
3584            Some(&attempt),
3585        );
3586        let prepared = plan.prepared_task().expect("verified prepared task");
3587        let command_path = prepared
3588            .paths()
3589            .control_dir
3590            .join(crate::bash_background::persistence::COMMAND_FILE);
3591        let victim = project.path().join("victim");
3592        std::fs::write(&victim, b"victim-bytes").unwrap();
3593        let payload = prepared.invocation().unwrap();
3594        // Mutate the same inode after verification. Execution must use the
3595        // verified in-memory buffers rather than rereading the held file.
3596        std::fs::write(
3597            &command_path,
3598            format!("printf hacked > {}", victim.display()),
3599        )
3600        .unwrap();
3601        let exit_path = project.path().join("exit");
3602        let exit = OpenOptions::new()
3603            .read(true)
3604            .write(true)
3605            .create_new(true)
3606            .mode(0o600)
3607            .open(&exit_path)
3608            .unwrap();
3609        crate::bash_background::persistence::set_close_on_exec(exit.as_raw_fd(), false).unwrap();
3610        // Mirror production (apply_marker_fd_allowlist): the real marker fd is
3611        // dup2'd onto the low, single-digit CHILD_EXIT_FD before exec, and the
3612        // wrapper is handed that literal. POSIX sh (dash) only parses a
3613        // single-digit `>&N` redirect target, so passing a raw multi-digit fd
3614        // here would diverge from production and fail under dash with
3615        // "Bad fd number" (production never hits this: it always remaps to 3).
3616        let raw_exit_fd = exit.as_raw_fd();
3617        let exit_fd = CHILD_EXIT_FD.to_string();
3618        let mut command = Command::new("/bin/sh");
3619        command.args([
3620            OsStr::new("-c"),
3621            payload.wrapper_text.as_os_str(),
3622            OsStr::new("aft-payload-wrapper"),
3623            OsStr::new("/bin/sh"),
3624            payload.command_text.as_os_str(),
3625            OsStr::new(&exit_fd),
3626        ]);
3627        {
3628            use std::os::unix::process::CommandExt;
3629            unsafe {
3630                command.pre_exec(move || {
3631                    if raw_exit_fd != CHILD_EXIT_FD && libc::dup2(raw_exit_fd, CHILD_EXIT_FD) < 0 {
3632                        return Err(std::io::Error::last_os_error());
3633                    }
3634                    Ok(())
3635                });
3636            }
3637        }
3638        let status = command.status().unwrap();
3639        assert!(status.success());
3640        assert_eq!(std::fs::read(&victim).unwrap(), b"victim-bytes");
3641    }
3642
3643    #[cfg(unix)]
3644    #[test]
3645    fn approval_spawn_drift_matrix_refuses_every_bound_field() {
3646        let project = tempfile::tempdir().unwrap();
3647        let ctx = context(project.path().to_path_buf());
3648        let principal = AuthenticatedPrincipal::FirstParty;
3649        let approved = ChildEnvironment::from([(OsString::from("A"), OsString::from("one"))]);
3650        for drift in [
3651            "command",
3652            "newline",
3653            "encoding",
3654            "cwd",
3655            "root",
3656            "shell",
3657            "environment",
3658            "environment_file_encoding",
3659            "wrapper_template",
3660        ] {
3661            let grant_id = mint_test_grant(
3662                &ctx,
3663                &principal,
3664                project.path(),
3665                b"printf approved",
3666                &approved,
3667                Instant::now(),
3668            );
3669            let mut attempt = grant_attempt(
3670                grant_id,
3671                project.path(),
3672                b"printf approved",
3673                approved.clone(),
3674            );
3675            match drift {
3676                "command" => attempt.command = b"printf changed".to_vec(),
3677                "newline" => attempt.command.push(b'\n'),
3678                "encoding" => attempt.command.push(0xff),
3679                "cwd" => attempt.cwd = project.path().join("changed-cwd"),
3680                "root" => attempt.root = project.path().join("changed-root"),
3681                "shell" => attempt.shell_path = PathBuf::from("/bin/bash"),
3682                "environment" => {
3683                    attempt
3684                        .environment
3685                        .insert(OsString::from("A"), OsString::from("two"));
3686                }
3687                "environment_file_encoding" | "wrapper_template" => {
3688                    let (session_dir, task_id) = grant_task_paths(&ctx, &attempt.grant_id);
3689                    let name = if drift == "wrapper_template" {
3690                        crate::bash_background::persistence::WRAPPER_FILE
3691                    } else {
3692                        crate::bash_background::persistence::ENVIRONMENT_FILE
3693                    };
3694                    std::fs::write(
3695                        session_dir.join(task_id).join("control").join(name),
3696                        b"drift",
3697                    )
3698                    .unwrap();
3699                }
3700                _ => unreachable!(),
3701            }
3702            let refused = resolve_sandbox_spawn(
3703                &ctx,
3704                &principal,
3705                RequestedSandboxTier::Host,
3706                SandboxTaskKind::BashForeground,
3707                project.path(),
3708                Some(&attempt),
3709            );
3710            assert_eq!(refusal_class(&refused), Some("digest_mismatch"), "{drift}");
3711        }
3712    }
3713
3714    #[cfg(unix)]
3715    #[test]
3716    fn payload_read_grant_seam_exposes_only_exact_control_objects() {
3717        let storage = tempfile::tempdir().unwrap();
3718        let project = tempfile::tempdir().unwrap();
3719        let principal = AuthenticatedPrincipal::FirstParty;
3720        let environment = ChildEnvironment::from([(OsString::from("SAFE"), OsString::from("yes"))]);
3721        let layout =
3722            crate::bash_background::persistence::allocate_task_layout(storage.path(), "session")
3723                .unwrap();
3724        let task = prepare_task_payload(
3725            &layout,
3726            b"true",
3727            project.path(),
3728            project.path(),
3729            &principal,
3730            Path::new("/bin/sh"),
3731            &environment,
3732        )
3733        .unwrap();
3734        let plan = SpawnPlan::Unsandboxed.with_prepared_task(task.clone());
3735        let grants = plan.payload_read_grants();
3736        assert_eq!(grants.len(), 3);
3737        assert_eq!(grants, task.payload_read_grants());
3738        assert!(grants
3739            .iter()
3740            .all(|path| path.parent() == Some(task.paths().control_dir.as_path())));
3741        assert!(!grants.contains(&task.paths().manifest));
3742        let sandbox_temp = storage.path().join("sandbox-temp");
3743        std::fs::create_dir_all(&sandbox_temp).unwrap();
3744        let b2_profile = SandboxProfile::build(
3745            vec![project.path().to_path_buf()],
3746            Vec::new(),
3747            Vec::new(),
3748            Vec::new(),
3749            vec![task.paths().control_dir.clone()],
3750            Vec::new(),
3751            Vec::new(),
3752            sandbox_temp,
3753        )
3754        .unwrap();
3755        assert!(b2_profile
3756            .read_deny
3757            .contains(&std::fs::canonicalize(&task.paths().control_dir).unwrap()));
3758        assert!(grants.iter().all(|path| {
3759            path.parent() == Some(task.paths().control_dir.as_path())
3760                && path != &task.paths().manifest
3761        }));
3762
3763        #[cfg(target_os = "linux")]
3764        {
3765            let refused = SpawnPlan::launcher_for_test(b2_profile, PathBuf::from("/usr/bin/false"))
3766                .with_prepared_task(task.clone());
3767            assert_eq!(refused.refusal_code(), Some("sandbox_unavailable"));
3768            assert!(refused
3769                .refusal_message()
3770                .is_some_and(|message| message.contains("mandatory read root")));
3771
3772            let allowed_temp = storage.path().join("allowed-sandbox-temp");
3773            std::fs::create_dir_all(&allowed_temp).unwrap();
3774            let allowed_profile = SandboxProfile::build(
3775                vec![project.path().to_path_buf()],
3776                Vec::new(),
3777                Vec::new(),
3778                vec![project.path().to_path_buf()],
3779                Vec::new(),
3780                Vec::new(),
3781                Vec::new(),
3782                allowed_temp,
3783            )
3784            .unwrap();
3785            let prepared =
3786                SpawnPlan::launcher_for_test(allowed_profile, PathBuf::from("/usr/bin/true"))
3787                    .with_prepared_task(task.clone());
3788            let profile = match prepared {
3789                SpawnPlan::Prepared { plan, .. } => match *plan {
3790                    SpawnPlan::Launcher { profile, .. } => profile,
3791                    other => panic!("expected launcher plan, got {other:?}"),
3792                },
3793                other => panic!("expected prepared plan, got {other:?}"),
3794            };
3795            let canonical_payloads = grants
3796                .iter()
3797                .map(|path| path.canonicalize().unwrap())
3798                .collect::<Vec<_>>();
3799            assert!(canonical_payloads
3800                .iter()
3801                .all(|path| profile.read_allow.contains(path)));
3802            assert!(profile.read_allow.iter().all(|path| {
3803                !path.starts_with(&task.paths().control_dir) || canonical_payloads.contains(path)
3804            }));
3805            validate_final_read_rules(&profile.read_allow, &profile.read_deny).unwrap();
3806        }
3807
3808        assert_eq!(task.environment(), &environment);
3809    }
3810
3811    #[cfg(unix)]
3812    #[test]
3813    fn writable_roots_refuse_both_session_store_overlap_directions() {
3814        fn profile(base: &Path, write_root: PathBuf) -> SandboxProfile {
3815            let project = base.join("project");
3816            let home = base.join("home");
3817            let temp = base.join("temp");
3818            for path in [&project, &home, &temp, &write_root] {
3819                std::fs::create_dir_all(path).unwrap();
3820            }
3821            SandboxProfile::build(
3822                vec![project, write_root],
3823                Vec::new(),
3824                Vec::new(),
3825                Vec::new(),
3826                Vec::new(),
3827                Vec::new(),
3828                Vec::new(),
3829                temp,
3830            )
3831            .unwrap()
3832        }
3833
3834        let base = tempfile::tempdir().unwrap();
3835        let session = base.path().join("store/session");
3836        let io = session.join("bash-0000000000000001/io");
3837        let control = session.join("bash-0000000000000001/control");
3838        std::fs::create_dir_all(&io).unwrap();
3839        std::fs::create_dir_all(&control).unwrap();
3840        let canonical_session = std::fs::canonicalize(&session).unwrap();
3841        let canonical_io = std::fs::canonicalize(&io).unwrap();
3842
3843        let ancestor = profile(base.path(), base.path().join("store"));
3844        assert!(refuse_store_overlap(&ancestor, &canonical_session, &canonical_io).is_err());
3845        let descendant = profile(base.path(), control);
3846        assert!(refuse_store_overlap(&descendant, &canonical_session, &canonical_io).is_err());
3847        let allowed = profile(base.path(), io);
3848        assert!(refuse_store_overlap(&allowed, &canonical_session, &canonical_io).is_ok());
3849    }
3850
3851    #[cfg(windows)]
3852    #[test]
3853    fn enabled_host_request_is_refused_on_windows_without_a_grant() {
3854        let project = tempfile::tempdir().unwrap();
3855        let ctx = context(project.path().to_path_buf());
3856        let plan = resolve_sandbox_spawn(
3857            &ctx,
3858            &AuthenticatedPrincipal::FirstParty,
3859            RequestedSandboxTier::Host,
3860            SandboxTaskKind::BashForeground,
3861            project.path(),
3862            None,
3863        );
3864        assert_eq!(plan.refusal_code(), Some("sandbox_unavailable"));
3865        assert_eq!(
3866            plan.refusal_message(),
3867            Some(
3868                "sandbox is not supported on this platform; disable sandbox.enabled or run on macOS/Linux"
3869            )
3870        );
3871    }
3872
3873    #[cfg(windows)]
3874    #[test]
3875    fn enabled_native_tier_is_refused_on_windows() {
3876        let project = tempfile::tempdir().unwrap();
3877        let ctx = context(project.path().to_path_buf());
3878        let plan = resolve_sandbox_spawn(
3879            &ctx,
3880            &AuthenticatedPrincipal::FirstParty,
3881            RequestedSandboxTier::Native,
3882            SandboxTaskKind::BashForeground,
3883            project.path(),
3884            None,
3885        );
3886        assert_eq!(plan.refusal_code(), Some("sandbox_unavailable"));
3887        assert_eq!(
3888            plan.refusal_message(),
3889            Some(
3890                "sandbox is not supported on this platform; disable sandbox.enabled or run on macOS/Linux"
3891            )
3892        );
3893    }
3894}
3895
3896#[cfg(test)]
3897mod read_allow_tests {
3898    use super::*;
3899
3900    #[derive(Default)]
3901    struct FakeLister {
3902        entries: BTreeMap<PathBuf, Result<Vec<ListedReadChild>, String>>,
3903    }
3904
3905    impl FakeLister {
3906        fn directory(mut self, parent: &str, children: &[(&str, bool)]) -> Self {
3907            let parent = PathBuf::from(parent);
3908            self.entries.insert(
3909                parent.clone(),
3910                Ok(children
3911                    .iter()
3912                    .map(|(name, is_dir)| ListedReadChild {
3913                        path: parent.join(name),
3914                        is_dir: *is_dir,
3915                    })
3916                    .collect()),
3917            );
3918            self
3919        }
3920
3921        fn failure(mut self, parent: &str, message: &str) -> Self {
3922            self.entries
3923                .insert(PathBuf::from(parent), Err(message.to_string()));
3924            self
3925        }
3926    }
3927
3928    impl ReadDirectoryLister for FakeLister {
3929        fn children(&mut self, parent: &Path) -> Result<Vec<ListedReadChild>, String> {
3930            self.entries
3931                .remove(parent)
3932                .unwrap_or_else(|| Err(format!("unexpected enumeration of {}", parent.display())))
3933        }
3934    }
3935
3936    fn grant(path: &str, force_children: bool, mandatory: bool) -> IntendedReadGrant {
3937        IntendedReadGrant {
3938            path: PathBuf::from(path),
3939            force_children,
3940            mandatory,
3941        }
3942    }
3943
3944    #[test]
3945    fn read_grants_split_home_across_all_deny_chains() {
3946        let mut lister = FakeLister::default()
3947            .directory(
3948                "/home/alice",
3949                &[
3950                    (".ssh", true),
3951                    (".config", true),
3952                    ("work", true),
3953                    ("notes", false),
3954                ],
3955            )
3956            .directory(
3957                "/home/alice/.config",
3958                &[("gcloud", true), ("cortexkit", true), ("editor", true)],
3959            )
3960            .directory("/home/alice/work", &[("private", true), ("src", true)]);
3961        let denies = [
3962            "/home/alice/.ssh",
3963            "/home/alice/.config/gcloud",
3964            "/home/alice/.config/cortexkit",
3965            "/home/alice/work/private",
3966        ]
3967        .map(PathBuf::from);
3968
3969        let emitted = split_read_grants(&[grant("/home/alice", true, false)], &denies, &mut lister)
3970            .expect("split HOME grants");
3971
3972        assert_eq!(
3973            emitted,
3974            [
3975                "/home/alice/.config/editor",
3976                "/home/alice/notes",
3977                "/home/alice/work/src",
3978            ]
3979            .map(PathBuf::from)
3980        );
3981    }
3982
3983    #[test]
3984    fn secure_enumeration_omits_home_child_symlinks() {
3985        let mut lister = FakeLister::default()
3986            .directory("/home/alice", &[("ordinary", true), ("plain-file", false)]);
3987        let emitted = split_read_grants(
3988            &[grant("/home/alice", true, false)],
3989            &[PathBuf::from("/home/alice/.ssh")],
3990            &mut lister,
3991        )
3992        .expect("split HOME grants");
3993
3994        assert_eq!(
3995            emitted,
3996            ["/home/alice/ordinary", "/home/alice/plain-file"].map(PathBuf::from)
3997        );
3998        assert!(!emitted.iter().any(|path| path.ends_with("secret-link")));
3999    }
4000
4001    #[test]
4002    fn enumeration_race_refuses_instead_of_weakening_the_floor() {
4003        let mut lister = FakeLister::default().failure("/home/alice", "entry disappeared");
4004        let error = split_read_grants(
4005            &[grant("/home/alice", true, false)],
4006            &[PathBuf::from("/home/alice/.ssh")],
4007            &mut lister,
4008        )
4009        .expect_err("racing enumeration must fail closed");
4010
4011        assert!(error.contains("cannot split read root /home/alice"));
4012        assert!(error.contains("entry disappeared"));
4013    }
4014
4015    #[test]
4016    fn mandatory_floor_rejects_equal_containing_and_nested_writable_roots() {
4017        let floor = vec![PathBuf::from("/home/alice/.ssh")];
4018        for writable in [
4019            Path::new("/home/alice/.ssh"),
4020            Path::new("/home/alice"),
4021            Path::new("/home/alice/.ssh/cache"),
4022        ] {
4023            let error = validate_mandatory_floor_overlap([writable], &floor)
4024                .expect_err("mandatory floor overlap must refuse");
4025            assert!(error.contains("overlaps mandatory secret floor"));
4026        }
4027        validate_mandatory_floor_overlap([Path::new("/home/alice/project")], &floor)
4028            .expect("disjoint writable root");
4029    }
4030
4031    #[test]
4032    fn ordinary_read_deny_under_writable_root_is_split_not_refused() {
4033        let mut lister =
4034            FakeLister::default().directory("/project", &[("private", true), ("src", true)]);
4035        let writable_root = PathBuf::from("/project");
4036        let emitted = split_read_grants(
4037            &[IntendedReadGrant {
4038                path: writable_root.clone(),
4039                force_children: false,
4040                mandatory: false,
4041            }],
4042            &[PathBuf::from("/project/private")],
4043            &mut lister,
4044        )
4045        .expect("ordinary deny should be expressible");
4046
4047        assert_eq!(emitted, vec![PathBuf::from("/project/src")]);
4048        assert_eq!(writable_root, PathBuf::from("/project"));
4049    }
4050
4051    #[test]
4052    fn static_var_grant_splits_when_home_is_beneath_it() {
4053        let mut lister = FakeLister::default()
4054            .directory("/var", &[("home", true), ("log", true)])
4055            .directory("/var/home", &[("alice", true)])
4056            .directory("/var/home/alice", &[(".ssh", true), ("work", true)]);
4057        let emitted = split_read_grants(
4058            &[grant("/var", true, true)],
4059            &[PathBuf::from("/var/home/alice/.ssh")],
4060            &mut lister,
4061        )
4062        .expect("split /var around HOME floor");
4063
4064        assert_eq!(
4065            emitted,
4066            ["/var/home/alice/work", "/var/log"].map(PathBuf::from)
4067        );
4068    }
4069
4070    #[test]
4071    fn run_sensitive_directories_are_removed_by_canonical_deny_chain() {
4072        let mut lister = FakeLister::default().directory(
4073            "/run",
4074            &[
4075                ("lock", true),
4076                ("user", true),
4077                ("credentials", true),
4078                ("secrets", true),
4079            ],
4080        );
4081        let emitted = split_read_grants(
4082            &[grant("/run", false, true)],
4083            &[
4084                PathBuf::from("/run/user"),
4085                PathBuf::from("/run/credentials"),
4086                PathBuf::from("/run/secrets"),
4087            ],
4088            &mut lister,
4089        )
4090        .expect("split /run");
4091
4092        assert_eq!(emitted, vec![PathBuf::from("/run/lock")]);
4093        assert!(!emitted.iter().any(|path| {
4094            path == Path::new("/run/credentials") || path == Path::new("/run/secrets")
4095        }));
4096    }
4097
4098    #[test]
4099    fn final_validation_rejects_every_overlap_direction() {
4100        let deny = vec![PathBuf::from("/home/alice/.ssh")];
4101        for grant in [
4102            PathBuf::from("/home/alice"),
4103            PathBuf::from("/home/alice/.ssh"),
4104            PathBuf::from("/home/alice/.ssh/key"),
4105        ] {
4106            assert!(validate_final_read_rules(&[grant], &deny).is_err());
4107        }
4108        validate_final_read_rules(&[PathBuf::from("/home/alice/work")], &deny)
4109            .expect("disjoint final grant");
4110    }
4111
4112    #[test]
4113    fn grant_beneath_ordinary_deny_is_dropped_but_mandatory_grant_refuses() {
4114        let deny = vec![PathBuf::from("/restricted")];
4115        let mut lister = FakeLister::default();
4116        let emitted = split_read_grants(
4117            &[grant("/restricted/project", false, false)],
4118            &deny,
4119            &mut lister,
4120        )
4121        .expect("ordinary grant is optional");
4122        assert!(emitted.is_empty());
4123
4124        let error = split_read_grants(
4125            &[grant("/restricted/system", false, true)],
4126            &deny,
4127            &mut lister,
4128        )
4129        .expect_err("mandatory grant under deny must refuse");
4130        assert!(error.contains("mandatory read root"));
4131    }
4132
4133    #[cfg(unix)]
4134    #[test]
4135    fn linked_worktree_resolves_common_git_dir_and_shared_hooks() {
4136        // Neutralize ambient co_author hooksPath env injection (highest git
4137        // config scope) so the worktree's own hooks resolution decides.
4138        let _git_env = crate::test_env::hermetic_git_env_guard();
4139        let fixture = tempfile::tempdir().expect("fixture");
4140        let main = fixture.path().join("main");
4141        let worktree = fixture.path().join("linked");
4142        std::fs::create_dir(&main).expect("main repository");
4143        assert!(Command::new("git")
4144            .args(["init", "-q"])
4145            .current_dir(&main)
4146            .status()
4147            .expect("git init")
4148            .success());
4149        std::fs::write(main.join("tracked"), b"tracked").expect("tracked file");
4150        assert!(Command::new("git")
4151            .args(["add", "tracked"])
4152            .current_dir(&main)
4153            .status()
4154            .expect("git add")
4155            .success());
4156        assert!(Command::new("git")
4157            .args([
4158                "-c",
4159                "user.name=AFT Test",
4160                "-c",
4161                "user.email=aft@example.invalid",
4162                "commit",
4163                "-qm",
4164                "initial",
4165            ])
4166            .current_dir(&main)
4167            .status()
4168            .expect("git commit")
4169            .success());
4170        assert!(Command::new("git")
4171            .args(["worktree", "add", "-q"])
4172            .arg(&worktree)
4173            .arg("HEAD")
4174            .current_dir(&main)
4175            .status()
4176            .expect("git worktree add")
4177            .success());
4178
4179        let policy = resolve_git_policy(&worktree).expect("resolve linked worktree policy");
4180        let common = main.join(".git").canonicalize().expect("common git dir");
4181        assert_eq!(policy.hooks, vec![common.join("hooks")]);
4182        #[cfg(target_os = "linux")]
4183        assert!(policy.read_roots.contains(&common));
4184    }
4185
4186    #[cfg(unix)]
4187    #[test]
4188    fn configured_hooks_path_is_resolved_to_its_effective_location() {
4189        // Neutralize ambient co_author hooksPath env injection (highest git
4190        // config scope) so the temp repo's local core.hooksPath decides.
4191        let _git_env = crate::test_env::hermetic_git_env_guard();
4192        let fixture = tempfile::tempdir().expect("fixture");
4193        let project = fixture.path().join("project");
4194        std::fs::create_dir(&project).expect("project");
4195        assert!(Command::new("git")
4196            .args(["init", "-q"])
4197            .current_dir(&project)
4198            .status()
4199            .expect("git init")
4200            .success());
4201        assert!(Command::new("git")
4202            .args(["config", "core.hooksPath", "custom-hooks"])
4203            .current_dir(&project)
4204            .status()
4205            .expect("git config")
4206            .success());
4207
4208        let policy = resolve_git_policy(&project).expect("resolve configured hooks path");
4209        assert_eq!(
4210            policy.hooks,
4211            vec![project
4212                .canonicalize()
4213                .expect("canonical project")
4214                .join("custom-hooks")]
4215        );
4216    }
4217}