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