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