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