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