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