1use std::ffi::{OsStr, OsString};
29#[cfg(any(target_os = "linux", target_os = "macos"))]
30use std::io::{Read, Write};
31use std::path::{Path, PathBuf};
32use std::process::{Child, Command, Stdio};
33
34use std::sync::Arc;
35use std::time::Duration;
36
37use crate::{
38 best_available_sandbox, effective_sandbox_kind, enforcement_report, fence_strength,
39 AxisEnforcement, Caveats, SandboxKind, SandboxPolicy, ToolContext, ToolError, ToolResult,
40};
41use agent_mesh_protocol::Fingerprint;
42use serde::de::DeserializeOwned;
43use serde::{Deserialize, Serialize};
44#[cfg(test)]
48use crate::Scope;
49
50#[derive(Debug)]
56pub struct ConfinedChild {
57 pub child: Child,
59 pub sandbox_kind: SandboxKind,
61}
62
63#[derive(Debug)]
71pub struct SandboxedWorkerChild {
72 pub child: Child,
74 pub sandbox_kind: SandboxKind,
76 control: Option<TrustedWorkerControl>,
77}
78
79impl SandboxedWorkerChild {
80 pub fn send_payload<T: Serialize>(&mut self, payload: &T, timeout: Duration) -> ToolResult<()> {
88 let mut control = self
89 .control
90 .take()
91 .ok_or_else(|| ToolError::denied("trusted worker request was already sent"))?;
92 control.send(payload, self.child.id(), timeout)
93 }
94}
95
96#[derive(Debug)]
101struct TrustedWorkerControl {
102 #[cfg(any(target_os = "linux", target_os = "macos"))]
103 stream: std::os::unix::net::UnixStream,
104 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
105 unavailable: (),
106 nonce: String,
107 caveats: Caveats,
108 strength_floor: AxisEnforcement,
109}
110
111impl TrustedWorkerControl {
112 #[cfg(any(target_os = "linux", target_os = "macos"))]
113 fn send<T: Serialize>(
114 &mut self,
115 payload: &T,
116 child_pid: u32,
117 timeout: Duration,
118 ) -> ToolResult<()> {
119 self.stream
120 .set_read_timeout(Some(timeout))
121 .map_err(ToolError::from)?;
122 self.stream
123 .set_write_timeout(Some(timeout))
124 .map_err(ToolError::from)?;
125
126 self.stream
130 .write_all(&TRUSTED_WORKER_BOOTSTRAP)
131 .and_then(|()| self.stream.flush())
132 .map_err(ToolError::from)?;
133 let mut hello = [0_u8; TRUSTED_WORKER_HELLO_LEN];
134 self.stream
135 .read_exact(&mut hello)
136 .map_err(ToolError::from)?;
137 let (reported_pid, challenge) = decode_trusted_worker_hello(&hello)
138 .map_err(|error| ToolError::denied(format!("invalid worker hello: {error}")))?;
139 if reported_pid != child_pid {
140 return Err(ToolError::denied(format!(
141 "worker hello PID mismatch: spawned {child_pid}, reported {reported_pid}"
142 )));
143 }
144
145 let request = TrustedWorkerRequest {
146 version: TRUSTED_WORKER_PROTOCOL_VERSION,
147 nonce: self.nonce.clone(),
148 caveats: self.caveats.clone(),
149 strength_floor: self.strength_floor,
150 payload,
151 };
152 let body = serde_json::to_vec(&request)
153 .map_err(|error| ToolError::denied(format!("encode worker request: {error}")))?;
154 if body.len() > TRUSTED_WORKER_MAX_BODY {
155 return Err(ToolError::denied(
156 "trusted worker request exceeds its 1 MiB cap",
157 ));
158 }
159 let header = encode_trusted_worker_frame_header(
160 challenge,
161 trusted_worker_frame_digest(&challenge, &body),
162 body.len(),
163 )
164 .map_err(ToolError::denied)?;
165 self.stream.write_all(&header).map_err(ToolError::from)?;
166 self.stream.write_all(&body).map_err(ToolError::from)?;
167 self.stream.flush().map_err(ToolError::from)?;
168 let mut ack = [0_u8; TRUSTED_WORKER_ACK.len()];
169 self.stream.read_exact(&mut ack).map_err(ToolError::from)?;
170 if ack != TRUSTED_WORKER_ACK {
171 return Err(ToolError::denied(
172 "trusted worker returned an invalid authentication ACK",
173 ));
174 }
175 let _ = self.stream.shutdown(std::net::Shutdown::Write);
179 Ok(())
180 }
181
182 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
183 fn send<T: Serialize>(
184 &mut self,
185 payload: &T,
186 child_pid: u32,
187 timeout: Duration,
188 ) -> ToolResult<()> {
189 let _ = (
190 &self.unavailable,
191 &self.nonce,
192 &self.caveats,
193 self.strength_floor,
194 payload,
195 child_pid,
196 timeout,
197 );
198 Err(ToolError::denied(
199 "trusted worker control channels are unavailable on this platform",
200 ))
201 }
202}
203
204pub const TRUSTED_WORKER_PROTOCOL_VERSION: u8 = 1;
206pub const TRUSTED_WORKER_MAX_BODY: usize = 1024 * 1024;
208pub const TRUSTED_WORKER_BOOTSTRAP: [u8; 8] = *b"ABTW-B1\0";
210pub const TRUSTED_WORKER_ACK: [u8; 8] = *b"ABTW-A1\0";
212const TRUSTED_WORKER_HELLO_MAGIC: [u8; 8] = *b"ABTW-H1\0";
213const TRUSTED_WORKER_FRAME_MAGIC: [u8; 8] = *b"ABTW-R1\0";
214pub const TRUSTED_WORKER_HELLO_LEN: usize = 8 + 4 + 32;
216pub const TRUSTED_WORKER_FRAME_HEADER_LEN: usize = 8 + 4 + 32 + 32;
218const TRUSTED_WORKER_DIGEST_DOMAIN: &[u8] = b"agent-bridle/trusted-worker-frame/v1";
219
220#[derive(Debug, Serialize, Deserialize)]
226pub struct TrustedWorkerRequest<P> {
227 version: u8,
228 nonce: String,
229 caveats: Caveats,
230 strength_floor: AxisEnforcement,
231 payload: P,
232}
233
234impl<P> TrustedWorkerRequest<P> {
235 #[must_use]
237 pub fn into_parts(self) -> (String, Caveats, AxisEnforcement, P) {
238 (self.nonce, self.caveats, self.strength_floor, self.payload)
239 }
240
241 #[must_use]
243 pub fn has_supported_version(&self) -> bool {
244 self.version == TRUSTED_WORKER_PROTOCOL_VERSION
245 }
246}
247
248#[must_use]
250pub fn encode_trusted_worker_hello(child_pid: u32, challenge: [u8; 32]) -> [u8; 44] {
251 let mut frame = [0_u8; TRUSTED_WORKER_HELLO_LEN];
252 frame[..8].copy_from_slice(&TRUSTED_WORKER_HELLO_MAGIC);
253 frame[8..12].copy_from_slice(&child_pid.to_le_bytes());
254 frame[12..].copy_from_slice(&challenge);
255 frame
256}
257
258pub fn decode_trusted_worker_hello(frame: &[u8]) -> Result<(u32, [u8; 32]), String> {
260 if frame.len() != TRUSTED_WORKER_HELLO_LEN || frame[..8] != TRUSTED_WORKER_HELLO_MAGIC {
261 return Err("bad trusted-worker hello framing".to_string());
262 }
263 let pid = u32::from_le_bytes(
264 frame[8..12]
265 .try_into()
266 .map_err(|_| "bad trusted-worker PID field")?,
267 );
268 let challenge = frame[12..]
269 .try_into()
270 .map_err(|_| "bad trusted-worker challenge field")?;
271 Ok((pid, challenge))
272}
273
274pub fn encode_trusted_worker_frame_header(
277 challenge: [u8; 32],
278 digest: [u8; 32],
279 body_len: usize,
280) -> Result<[u8; TRUSTED_WORKER_FRAME_HEADER_LEN], String> {
281 let body_len = u32::try_from(body_len)
282 .map_err(|_| "trusted-worker request length does not fit its frame".to_string())?;
283 let mut frame = [0_u8; TRUSTED_WORKER_FRAME_HEADER_LEN];
284 frame[..8].copy_from_slice(&TRUSTED_WORKER_FRAME_MAGIC);
285 frame[8..12].copy_from_slice(&body_len.to_le_bytes());
286 frame[12..44].copy_from_slice(&challenge);
287 frame[44..].copy_from_slice(&digest);
288 Ok(frame)
289}
290
291pub fn decode_trusted_worker_frame_header(
293 frame: &[u8],
294) -> Result<(usize, [u8; 32], [u8; 32]), String> {
295 if frame.len() != TRUSTED_WORKER_FRAME_HEADER_LEN || frame[..8] != TRUSTED_WORKER_FRAME_MAGIC {
296 return Err("bad trusted-worker response framing".to_string());
297 }
298 let body_len = u32::from_le_bytes(
299 frame[8..12]
300 .try_into()
301 .map_err(|_| "bad trusted-worker length field")?,
302 ) as usize;
303 if body_len > TRUSTED_WORKER_MAX_BODY {
304 return Err("trusted-worker request exceeds its 1 MiB cap".to_string());
305 }
306 let challenge = frame[12..44]
307 .try_into()
308 .map_err(|_| "bad trusted-worker challenge field")?;
309 let digest = frame[44..]
310 .try_into()
311 .map_err(|_| "bad trusted-worker digest field")?;
312 Ok((body_len, challenge, digest))
313}
314
315#[must_use]
317pub fn trusted_worker_frame_digest(challenge: &[u8; 32], body: &[u8]) -> [u8; 32] {
318 let mut framed =
319 Vec::with_capacity(TRUSTED_WORKER_DIGEST_DOMAIN.len() + challenge.len() + body.len());
320 framed.extend_from_slice(TRUSTED_WORKER_DIGEST_DOMAIN);
321 framed.extend_from_slice(challenge);
322 framed.extend_from_slice(body);
323 Fingerprint::of_bytes(&framed).0
324}
325
326pub fn decode_trusted_worker_request<P: DeserializeOwned>(
328 body: &[u8],
329) -> Result<TrustedWorkerRequest<P>, String> {
330 serde_json::from_slice(body).map_err(|error| format!("invalid trusted-worker request: {error}"))
331}
332
333#[derive(Debug)]
341pub struct ConfinedCommand {
342 program: String,
343 args: Vec<OsString>,
344 envs: Vec<(OsString, OsString)>,
345 cwd: Option<PathBuf>,
346 stdin: Option<Stdio>,
347 stdout: Option<Stdio>,
348 stderr: Option<Stdio>,
349 new_process_group: bool,
352 sandbox_policy: Arc<SandboxPolicy>,
356 #[cfg(all(unix, feature = "spawn-tokio"))]
357 private_hosts: std::collections::HashSet<String>,
358}
359
360impl ConfinedCommand {
361 pub fn new(program: impl Into<String>) -> Self {
363 Self {
364 program: program.into(),
365 args: Vec::new(),
366 envs: Vec::new(),
367 cwd: None,
368 stdin: None,
369 stdout: None,
370 stderr: None,
371 new_process_group: false,
372 sandbox_policy: Arc::new(SandboxPolicy::default()),
373 #[cfg(all(unix, feature = "spawn-tokio"))]
374 private_hosts: std::collections::HashSet::new(),
375 }
376 }
377
378 #[must_use]
381 pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
382 self.sandbox_policy = policy;
383 self
384 }
385
386 #[must_use]
388 pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
389 self.args.push(arg.as_ref().to_os_string());
390 self
391 }
392
393 #[must_use]
395 pub fn args<I, S>(mut self, args: I) -> Self
396 where
397 I: IntoIterator<Item = S>,
398 S: AsRef<OsStr>,
399 {
400 self.args
401 .extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
402 self
403 }
404
405 #[must_use]
408 pub fn env(mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> Self {
409 self.envs
410 .push((key.as_ref().to_os_string(), val.as_ref().to_os_string()));
411 self
412 }
413
414 #[must_use]
416 pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
417 self.cwd = Some(dir.as_ref().to_path_buf());
418 self
419 }
420
421 #[must_use]
423 pub fn stdin(mut self, cfg: Stdio) -> Self {
424 self.stdin = Some(cfg);
425 self
426 }
427
428 #[must_use]
430 pub fn stdout(mut self, cfg: Stdio) -> Self {
431 self.stdout = Some(cfg);
432 self
433 }
434
435 #[must_use]
437 pub fn stderr(mut self, cfg: Stdio) -> Self {
438 self.stderr = Some(cfg);
439 self
440 }
441
442 #[must_use]
448 pub fn new_process_group(mut self) -> Self {
449 self.new_process_group = true;
450 self
451 }
452
453 pub fn spawn(self, cx: &ToolContext) -> ToolResult<ConfinedChild> {
460 let effective = cx.caveats().clone();
461 self.spawn_with_effective(cx, effective)
462 }
463
464 fn spawn_with_effective(
471 self,
472 cx: &ToolContext,
473 effective: Caveats,
474 ) -> ToolResult<ConfinedChild> {
475 self.spawn_authorized(cx, effective, SpawnAuthority::ModelSelected)
476 }
477
478 fn spawn_authorized(
482 self,
483 cx: &ToolContext,
484 effective: Caveats,
485 authority: SpawnAuthority,
486 ) -> ToolResult<ConfinedChild> {
487 if authority == SpawnAuthority::ModelSelected {
491 cx.check_exec(&self.program)?;
492 }
493
494 let sandbox = best_available_sandbox(&self.sandbox_policy);
495 let kind = sandbox.kind();
496 let reported_kind = effective_sandbox_kind(kind, &effective);
505
506 if confinement_unenforceable(reported_kind, &effective, cx.strength_floor()) {
509 return Err(ToolError::denied(format!(
510 "refusing to spawn {:?}: a restricted filesystem/exec/net axis cannot be \
511 enforced on a subprocess at the required strength floor ({:?}) by the \
512 governing sandbox ({:?})",
513 self.program,
514 cx.strength_floor(),
515 reported_kind
516 )));
517 }
518
519 let mechanism_effective = if authority == SpawnAuthority::TrustedWorker {
529 trusted_worker_mechanism_caveats(kind, &effective, &self.program)?
530 } else {
531 effective.clone()
532 };
533 let prefix = sandbox.command_prefix(&mechanism_effective)?;
534
535 let Self {
539 program,
540 args,
541 envs,
542 cwd,
543 stdin,
544 stdout,
545 stderr,
546 new_process_group,
547 sandbox_policy: _,
549 #[cfg(all(unix, feature = "spawn-tokio"))]
550 private_hosts: _,
551 } = self;
552
553 let spawned = std::thread::spawn(move || -> ToolResult<Child> {
554 if prefix.is_empty() {
565 sandbox.apply(&mechanism_effective)?;
566 }
567
568 let (spawn_program, spawn_args) = wrap_argv(&prefix, &program, &args);
571 let mut cmd = Command::new(&spawn_program);
572 cmd.args(&spawn_args);
573 cmd.env_clear(); for (k, v) in &envs {
575 cmd.env(k, v); }
577 if let Some(dir) = &cwd {
578 cmd.current_dir(dir);
579 }
580 if let Some(cfg) = stdin {
581 cmd.stdin(cfg);
582 }
583 if let Some(cfg) = stdout {
584 cmd.stdout(cfg);
585 }
586 if let Some(cfg) = stderr {
587 cmd.stderr(cfg);
588 }
589 #[cfg(unix)]
590 if new_process_group {
591 use std::os::unix::process::CommandExt;
592 cmd.process_group(0);
593 }
594 #[cfg(not(unix))]
595 let _ = new_process_group;
596 cmd.spawn().map_err(ToolError::from)
597 })
598 .join()
599 .map_err(|_| ToolError::denied("confined-spawn thread panicked before exec"))?;
600
601 Ok(ConfinedChild {
602 child: spawned?,
603 sandbox_kind: reported_kind,
604 })
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609enum SpawnAuthority {
610 ModelSelected,
611 TrustedWorker,
612}
613
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub enum TrustedWorkerKind {
618 Brush,
620}
621
622impl TrustedWorkerKind {
623 #[cfg(any(target_os = "linux", target_os = "macos"))]
624 fn args(self) -> [&'static str; 2] {
625 match self {
626 Self::Brush => ["--agent-bridle-worker", "brush"],
627 }
628 }
629}
630
631#[derive(Debug, Clone)]
637pub struct SandboxedWorker {
638 #[cfg(any(target_os = "linux", target_os = "macos"))]
639 kind: TrustedWorkerKind,
640 sandbox_policy: Arc<SandboxPolicy>,
641}
642
643impl SandboxedWorker {
644 #[must_use]
650 pub fn brush() -> Self {
651 Self {
652 #[cfg(any(target_os = "linux", target_os = "macos"))]
653 kind: TrustedWorkerKind::Brush,
654 sandbox_policy: Arc::new(SandboxPolicy::default()),
655 }
656 }
657
658 #[must_use]
660 pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
661 self.sandbox_policy = policy;
662 self
663 }
664
665 pub fn spawn(
675 self,
676 cx: &ToolContext,
677 nonce: &str,
678 cwd: &Path,
679 ) -> ToolResult<SandboxedWorkerChild> {
680 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
681 {
682 let _ = (self, cx, nonce, cwd);
683 Err(ToolError::denied(
684 "refusing the Brush worker: this platform has no authenticated \
685 private-control transport",
686 ))
687 }
688 #[cfg(any(target_os = "linux", target_os = "macos"))]
689 {
690 self.spawn_supported(cx, nonce, cwd)
691 }
692 }
693
694 #[cfg(any(target_os = "linux", target_os = "macos"))]
695 fn spawn_supported(
696 self,
697 cx: &ToolContext,
698 nonce: &str,
699 cwd: &Path,
700 ) -> ToolResult<SandboxedWorkerChild> {
701 cx.check_path_read(cwd)?;
702 let request_caveats = cx.caveats().clone();
703 let request_strength_floor = cx.strength_floor();
704 let executable = std::env::current_exe()
705 .and_then(std::fs::canonicalize)
706 .map_err(|error| ToolError::denied(format!("worker executable is invalid: {error}")))?;
707 let executable = executable.to_string_lossy().into_owned();
708 let [flag, kind] = self.kind.args();
709 #[cfg(any(target_os = "linux", target_os = "macos"))]
710 let (control, child_control) = std::os::unix::net::UnixStream::pair().map_err(|error| {
711 ToolError::denied(format!("create worker control channel: {error}"))
712 })?;
713
714 let command = ConfinedCommand::new(executable)
715 .args([flag, kind])
716 .env("AGENT_BRIDLE_WORKER_NONCE", nonce)
717 .current_dir(cwd)
718 .stdin(Stdio::from(std::os::fd::OwnedFd::from(child_control)))
719 .stdout(Stdio::piped())
720 .stderr(Stdio::piped())
721 .new_process_group()
722 .sandbox_policy(self.sandbox_policy);
723
724 let confined = if crate::is_unbridled() {
725 command.spawn_authorized(cx, Caveats::top(), SpawnAuthority::TrustedWorker)
726 } else {
727 let effective = cx.caveats().clone();
728 let available = best_available_sandbox(&command.sandbox_policy).kind();
729 let reported = effective_sandbox_kind(available, &effective);
730 #[cfg(not(target_os = "linux"))]
731 let _ = reported;
732 #[cfg(target_os = "linux")]
733 if reported == SandboxKind::Landlock
734 && crate::sandbox::restricts_fs(&effective)
735 && !linux_user_namespaces_hardened()
736 {
737 return Err(ToolError::denied(
738 "refusing the Brush worker: Landlock filesystem confinement \
739 is not a complete boundary while unprivileged user namespaces \
740 remain available; disable them or add the namespace syscall backstop",
741 ));
742 }
743 command.spawn_authorized(cx, effective, SpawnAuthority::TrustedWorker)
744 }?;
745 Ok(SandboxedWorkerChild {
746 child: confined.child,
747 sandbox_kind: confined.sandbox_kind,
748 control: Some(TrustedWorkerControl {
749 stream: control,
750 nonce: nonce.to_string(),
751 caveats: request_caveats,
752 strength_floor: request_strength_floor,
753 }),
754 })
755 }
756}
757
758fn trusted_worker_mechanism_caveats(
771 kind: SandboxKind,
772 effective: &Caveats,
773 program: &str,
774) -> ToolResult<Caveats> {
775 match kind {
776 SandboxKind::Landlock
777 | SandboxKind::Seatbelt
778 | SandboxKind::MinimalRootfs
779 | SandboxKind::MicroVm => caveats_with_trusted_program(effective, program),
780 SandboxKind::AppContainer | SandboxKind::None => Ok(effective.clone()),
781 }
782}
783
784fn caveats_with_trusted_program(effective: &Caveats, program: &str) -> ToolResult<Caveats> {
787 let canonical = Path::new(program)
788 .canonicalize()
789 .map_err(|error| ToolError::denied(format!("cannot resolve trusted worker: {error}")))?
790 .to_string_lossy()
791 .into_owned();
792 let mut mechanism = effective.clone();
793 if let crate::Scope::Only(programs) = &mut mechanism.exec {
794 programs.insert(canonical);
795 }
796 Ok(mechanism)
797}
798
799#[cfg(target_os = "linux")]
800fn linux_user_namespaces_hardened() -> bool {
801 fn sysctl_is(path: &str, expected: &str) -> bool {
802 std::fs::read_to_string(path).is_ok_and(|value| value.trim() == expected)
803 }
804
805 sysctl_is("/proc/sys/kernel/unprivileged_userns_clone", "0")
806 || sysctl_is("/proc/sys/user/max_user_namespaces", "0")
807 || sysctl_is(
808 "/proc/sys/kernel/apparmor_restrict_unprivileged_userns",
809 "1",
810 )
811}
812
813pub fn spawn_confined_subprocess(
819 program: &str,
820 args: &[String],
821 cx: &ToolContext,
822 env_allow: &[(String, String)],
823 cwd: Option<&Path>,
824) -> ToolResult<ConfinedChild> {
825 let mut cmd = ConfinedCommand::new(program).args(args);
826 for (k, v) in env_allow {
827 cmd = cmd.env(k, v);
828 }
829 if let Some(dir) = cwd {
830 cmd = cmd.current_dir(dir);
831 }
832 cmd.spawn(cx)
833}
834
835#[cfg(all(unix, feature = "spawn-tokio"))]
847pub use tokio_spawn::ConfinedTokioChild;
848
849#[cfg(all(unix, feature = "spawn-tokio"))]
850mod tokio_spawn {
851 use super::{ConfinedChild, ConfinedCommand, SandboxKind, ToolContext, ToolResult};
852 use crate::net_proxy::ProxyHandle;
853 use crate::{egress_proxy_plan, ToolError};
854 use std::os::fd::OwnedFd;
855 use std::process::Child;
856 use tokio::net::unix::pipe;
857
858 #[derive(Debug)]
872 pub struct ConfinedTokioChild {
873 pub sandbox_kind: SandboxKind,
876 stdin: Option<pipe::Sender>,
877 stdout: Option<pipe::Receiver>,
878 stderr: Option<pipe::Receiver>,
879 child: Option<Child>,
881 proxy: Option<ProxyHandle>,
886 }
887
888 impl ConfinedTokioChild {
889 pub fn take_stdin(&mut self) -> Option<pipe::Sender> {
892 self.stdin.take()
893 }
894
895 pub fn take_stdout(&mut self) -> Option<pipe::Receiver> {
898 self.stdout.take()
899 }
900
901 pub fn take_stderr(&mut self) -> Option<pipe::Receiver> {
904 self.stderr.take()
905 }
906
907 pub fn egress_proxied(&self) -> bool {
911 self.proxy.is_some()
912 }
913
914 pub fn refused_hosts(&self) -> Vec<String> {
919 self.proxy
920 .as_ref()
921 .map(ProxyHandle::refused_hosts)
922 .unwrap_or_default()
923 }
924 }
925
926 impl Drop for ConfinedTokioChild {
927 fn drop(&mut self) {
928 if let Some(mut child) = self.child.take() {
933 let _ = child.kill();
934 std::thread::spawn(move || {
935 let _ = child.wait();
936 });
937 }
938 }
939 }
940
941 impl ConfinedCommand {
942 pub fn with_private_hosts(
951 mut self,
952 hosts: impl IntoIterator<Item = String>,
953 ) -> std::io::Result<Self> {
954 self.private_hosts = crate::net_proxy::canonical_private_hosts(hosts)?;
955 Ok(self)
956 }
957
958 pub fn spawn_tokio(mut self, cx: &ToolContext) -> ToolResult<ConfinedTokioChild> {
973 let mut proxy = None;
982 let mut effective = cx.caveats().clone();
983 if let Some((hosts, fenced)) = egress_proxy_plan(&effective, &self.sandbox_policy) {
984 let handle = crate::net_proxy::start_with_private_hosts(
987 hosts,
988 self.private_hosts.iter().cloned(),
989 std::sync::Arc::new(crate::net_proxy::StdResolver),
990 std::sync::Arc::new(crate::net_proxy::NullSink),
991 )
992 .map_err(|e| {
993 ToolError::Exec(std::io::Error::other(format!(
994 "refusing to spawn {:?}: the egress proxy could not bind \
995 loopback ({e})",
996 self.program
997 )))
998 })?;
999 for (k, v) in handle.proxy_env() {
1002 self = self.env(k, v);
1003 }
1004 proxy = Some(handle);
1005 effective = fenced;
1006 }
1007
1008 let ConfinedChild {
1009 mut child,
1010 sandbox_kind,
1011 } = self.spawn_with_effective(cx, effective)?;
1012
1013 let stdin = child
1020 .stdin
1021 .take()
1022 .map(|h| pipe::Sender::from_owned_fd(OwnedFd::from(h)))
1023 .transpose()?;
1024 let stdout = child
1025 .stdout
1026 .take()
1027 .map(|h| pipe::Receiver::from_owned_fd(OwnedFd::from(h)))
1028 .transpose()?;
1029 let stderr = child
1030 .stderr
1031 .take()
1032 .map(|h| pipe::Receiver::from_owned_fd(OwnedFd::from(h)))
1033 .transpose()?;
1034
1035 Ok(ConfinedTokioChild {
1036 sandbox_kind,
1037 stdin,
1038 stdout,
1039 stderr,
1040 child: Some(child),
1041 proxy,
1042 })
1043 }
1044 }
1045}
1046
1047fn wrap_argv(prefix: &[String], program: &str, args: &[OsString]) -> (OsString, Vec<OsString>) {
1054 if prefix.is_empty() {
1055 return (OsString::from(program), args.to_vec());
1056 }
1057 let mut argv: Vec<OsString> = prefix[1..].iter().map(OsString::from).collect();
1058 argv.push(OsString::from(program));
1059 argv.extend(args.iter().cloned());
1060 (OsString::from(&prefix[0]), argv)
1061}
1062
1063#[must_use]
1082pub fn confinement_unenforceable(
1083 kind: SandboxKind,
1084 caveats: &Caveats,
1085 floor: AxisEnforcement,
1086) -> bool {
1087 if crate::sandbox::has_unix_socket_grants(caveats) && kind != SandboxKind::Seatbelt {
1088 return true;
1089 }
1090 let report = enforcement_report(caveats, kind);
1091 let below_kernel = |e: Option<AxisEnforcement>| e.is_some_and(|e| e != AxisEnforcement::Kernel);
1092 if below_kernel(report.fs_write) || below_kernel(report.fs_read) {
1095 return true;
1096 }
1097 fence_strength(&report).is_some_and(|s| s < floor)
1099}
1100
1101#[cfg(all(unix, feature = "spawn-tokio", test))]
1106mod tokio_spawn_tests {
1107 use super::*;
1108 use crate::{Gate, Tool};
1109 use std::time::Duration;
1110 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1111
1112 fn ctx(granted: Caveats) -> ToolContext {
1113 struct AnyTool;
1114 #[async_trait::async_trait]
1115 impl Tool for AnyTool {
1116 fn name(&self) -> &str {
1117 "any"
1118 }
1119 fn schema(&self) -> serde_json::Value {
1120 serde_json::json!({})
1121 }
1122 async fn invoke(
1123 &self,
1124 _a: serde_json::Value,
1125 _c: &ToolContext,
1126 ) -> ToolResult<serde_json::Value> {
1127 Ok(serde_json::Value::Null)
1128 }
1129 }
1130 Gate::new(0)
1131 .authorize(&AnyTool, &granted)
1132 .expect("authorize")
1133 }
1134
1135 fn find_cat() -> Option<&'static str> {
1136 ["/usr/bin/cat", "/bin/cat"]
1137 .into_iter()
1138 .find(|p| Path::new(p).exists())
1139 }
1140
1141 #[test]
1142 fn exact_private_hosts_builder_defaults_closed_and_validates_names() {
1143 let command = ConfinedCommand::new("cat");
1144 assert!(command.private_hosts.is_empty());
1145 let command = command
1146 .with_private_hosts(["SERVICE.TEST.".to_string()])
1147 .unwrap();
1148 assert_eq!(command.private_hosts, ["service.test".to_string()].into());
1149 assert!(ConfinedCommand::new("cat")
1150 .with_private_hosts(["*".to_string()])
1151 .is_err());
1152 }
1153
1154 #[tokio::test]
1158 async fn json_line_round_trips_over_tokio_pipes() {
1159 let Some(cat) = find_cat() else {
1160 eprintln!("skipping: no cat(1) found");
1161 return;
1162 };
1163 let cx = ctx(Caveats {
1164 exec: Scope::only(["cat".to_string()]),
1165 ..Caveats::top()
1166 });
1167 let mut child = ConfinedCommand::new(cat)
1168 .stdin(Stdio::piped())
1169 .stdout(Stdio::piped())
1170 .spawn_tokio(&cx)
1171 .expect("spawn_tokio cat");
1172
1173 let mut stdin = child.take_stdin().expect("stdin piped");
1174 let stdout = child.take_stdout().expect("stdout piped");
1175 assert!(child.take_stdin().is_none(), "stdin taken once");
1176
1177 let msg = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
1178 stdin.write_all(msg.as_bytes()).await.expect("write");
1179 stdin.write_all(b"\n").await.expect("write nl");
1180 stdin.flush().await.expect("flush");
1181
1182 let mut lines = BufReader::new(stdout).lines();
1183 let got = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
1184 .await
1185 .expect("recv did not time out")
1186 .expect("recv ok");
1187 assert_eq!(got.as_deref(), Some(msg));
1188 }
1189
1190 #[tokio::test]
1195 async fn dropping_the_guard_kills_the_child() {
1196 let Some(cat) = find_cat() else {
1197 eprintln!("skipping: no cat(1) found");
1198 return;
1199 };
1200 let cx = ctx(Caveats {
1201 exec: Scope::only(["cat".to_string()]),
1202 ..Caveats::top()
1203 });
1204 let mut child = ConfinedCommand::new(cat)
1205 .stdin(Stdio::piped())
1206 .stdout(Stdio::piped())
1207 .spawn_tokio(&cx)
1208 .expect("spawn_tokio cat");
1209
1210 let stdout = child.take_stdout().expect("stdout piped");
1211 let _stdin = child.take_stdin().expect("stdin piped");
1213 drop(child);
1214
1215 let mut lines = BufReader::new(stdout).lines();
1216 let eof = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
1217 .await
1218 .expect("EOF did not time out")
1219 .expect("read ok");
1220 assert_eq!(
1221 eof, None,
1222 "kill-on-drop must terminate the child and close its stdout (EOF)"
1223 );
1224 }
1225
1226 #[tokio::test]
1234 async fn remote_net_grant_without_fence_backend_spawns_inert() {
1235 let caveats = Caveats {
1236 exec: Scope::only(["true".to_string()]),
1237 net: Scope::only(["api.example.com".to_string()]),
1238 ..Caveats::top()
1239 };
1240 let plan_engages = crate::egress_proxy_plan(
1243 &caveats,
1244 &std::sync::Arc::new(crate::SandboxPolicy::default()),
1245 )
1246 .is_some();
1247 if plan_engages {
1248 eprintln!("skipping: this host CAN emit the loopback fence (engage path)");
1249 return;
1250 }
1251 let cx = ctx(caveats);
1252 let child = ConfinedCommand::new("true")
1253 .spawn_tokio(&cx)
1254 .expect("inert path spawns as before");
1255 assert!(!child.egress_proxied(), "no fence backend → no proxy");
1256 assert!(child.refused_hosts().is_empty());
1257 drop(child);
1259 }
1260
1261 #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1273 #[tokio::test]
1274 #[ignore = "integration: real Seatbelt fence + curl subprocess + loopback proxy"]
1275 async fn remote_net_grant_with_fence_spawns_proxied_and_refuses_off_list() {
1276 if !crate::seatbelt_is_supported() {
1277 eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1278 return;
1279 }
1280 let curl = "/usr/bin/curl"; let caveats = Caveats {
1282 exec: Scope::only([curl.to_string()]),
1283 net: Scope::only(["api.example.com".to_string()]),
1284 ..Caveats::top()
1285 };
1286 let cx = ctx(caveats);
1287 let child = ConfinedCommand::new(curl)
1290 .with_private_hosts(["evil.example.net".to_string()])
1292 .expect("exact private host")
1293 .arg("-s")
1294 .arg("-m")
1295 .arg("5")
1296 .arg("https://evil.example.net/")
1297 .stdout(Stdio::null())
1298 .stderr(Stdio::null())
1299 .spawn_tokio(&cx)
1300 .expect("proxied spawn");
1301 assert!(child.egress_proxied(), "fence host → proxy must engage");
1302
1303 let _ = tokio::time::timeout(Duration::from_secs(10), async {
1306 tokio::time::sleep(Duration::from_secs(1)).await;
1307 })
1308 .await;
1309 assert!(
1310 child
1311 .refused_hosts()
1312 .contains(&"evil.example.net".to_string()),
1313 "the off-allow-list host must be refused and recorded: {:?}",
1314 child.refused_hosts()
1315 );
1316 }
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 use super::*;
1322 use crate::{Gate, Tool};
1323
1324 fn ctx(granted: Caveats) -> ToolContext {
1326 struct AnyTool;
1327 #[async_trait::async_trait]
1328 impl Tool for AnyTool {
1329 fn name(&self) -> &str {
1330 "any"
1331 }
1332 fn schema(&self) -> serde_json::Value {
1333 serde_json::json!({})
1334 }
1335 async fn invoke(
1336 &self,
1337 _args: serde_json::Value,
1338 _cx: &ToolContext,
1339 ) -> ToolResult<serde_json::Value> {
1340 Ok(serde_json::Value::Null)
1341 }
1342 }
1343 Gate::new(0)
1344 .authorize(&AnyTool, &granted)
1345 .expect("authorize")
1346 }
1347
1348 #[cfg(any(target_os = "linux", target_os = "macos"))]
1353 #[test]
1354 fn trusted_worker_control_freezes_authority_and_is_take_once() {
1355 let true_program = ["/usr/bin/true", "/bin/true"]
1356 .into_iter()
1357 .find(|path| Path::new(path).exists())
1358 .expect("true executable");
1359 let process = Command::new(true_program)
1360 .spawn()
1361 .expect("spawn PID holder");
1362 let child_pid = process.id();
1363 let (client, mut server) =
1364 std::os::unix::net::UnixStream::pair().expect("private socketpair");
1365 let (seen_tx, seen_rx) = std::sync::mpsc::channel();
1366
1367 let peer = std::thread::spawn(move || {
1368 let mut bootstrap = [0_u8; TRUSTED_WORKER_BOOTSTRAP.len()];
1369 server.read_exact(&mut bootstrap).expect("read bootstrap");
1370 assert_eq!(bootstrap, TRUSTED_WORKER_BOOTSTRAP);
1371 let challenge = [0x5a; 32];
1372 server
1373 .write_all(&encode_trusted_worker_hello(child_pid, challenge))
1374 .and_then(|()| server.flush())
1375 .expect("write hello");
1376
1377 let mut header = [0_u8; TRUSTED_WORKER_FRAME_HEADER_LEN];
1378 server.read_exact(&mut header).expect("read header");
1379 let (body_len, echoed, digest) =
1380 decode_trusted_worker_frame_header(&header).expect("decode header");
1381 assert_eq!(echoed, challenge);
1382 let mut body = vec![0_u8; body_len];
1383 server.read_exact(&mut body).expect("read body");
1384 assert_eq!(digest, trusted_worker_frame_digest(&challenge, &body));
1385 let request: TrustedWorkerRequest<serde_json::Value> =
1386 decode_trusted_worker_request(&body).expect("decode request");
1387 seen_tx.send(request.into_parts()).expect("report request");
1388 server
1389 .write_all(&TRUSTED_WORKER_ACK)
1390 .and_then(|()| server.flush())
1391 .expect("write ACK");
1392 });
1393
1394 let frozen = Caveats {
1395 exec: Scope::only(["echo".to_string()]),
1396 ..Caveats::top()
1397 };
1398 let control = TrustedWorkerControl {
1399 stream: client,
1400 nonce: "core-owned-nonce".to_string(),
1401 caveats: frozen.clone(),
1402 strength_floor: AxisEnforcement::Advisory,
1403 };
1404 let mut worker = SandboxedWorkerChild {
1405 child: process,
1406 sandbox_kind: SandboxKind::None,
1407 control: Some(control),
1408 };
1409 let forged_payload = serde_json::json!({
1410 "cmd": "echo ok",
1411 "caveats": Caveats::top(),
1412 "strength_floor": AxisEnforcement::Kernel,
1413 });
1414 worker
1415 .send_payload(&forged_payload, Duration::from_secs(5))
1416 .expect("first payload");
1417
1418 let (nonce, authority, floor, payload) = seen_rx.recv().expect("receive decoded request");
1419 assert_eq!(nonce, "core-owned-nonce");
1420 assert_eq!(authority, frozen, "payload must not replace frozen caveats");
1421 assert_eq!(floor, AxisEnforcement::Advisory);
1422 assert_eq!(payload, forged_payload);
1423 assert!(
1424 matches!(
1425 worker.send_payload(&serde_json::json!({}), Duration::from_secs(1)),
1426 Err(ToolError::Denied { .. })
1427 ),
1428 "the private control endpoint must be take-once"
1429 );
1430
1431 peer.join().expect("join fake worker");
1432 let _ = worker.child.wait();
1433 }
1434
1435 #[test]
1436 fn exec_outside_scope_is_denied_before_any_spawn() {
1437 let cx = ctx(Caveats {
1438 exec: Scope::only(["echo".to_string()]),
1439 ..Caveats::top()
1440 });
1441 let res = ConfinedCommand::new("rm").arg("-rf").spawn(&cx);
1442 assert!(matches!(res, Err(ToolError::Denied { .. })));
1443 }
1444
1445 #[test]
1446 fn unenforceable_predicate_fs_axes_always_strength_floor_for_exec() {
1447 use AxisEnforcement::{Advisory, Kernel};
1448 let fs_write = Caveats {
1449 fs_write: Scope::only(["/tmp/x".to_string()]),
1450 ..Caveats::top()
1451 };
1452 let fs_read = Caveats {
1453 fs_read: Scope::only(["/tmp/x".to_string()]),
1454 ..Caveats::top()
1455 };
1456 let exec = Caveats {
1457 exec: Scope::only(["echo".to_string()]),
1458 ..Caveats::top()
1459 };
1460
1461 assert!(confinement_unenforceable(
1465 SandboxKind::None,
1466 &fs_write,
1467 Advisory
1468 ));
1469 assert!(confinement_unenforceable(
1470 SandboxKind::None,
1471 &fs_read,
1472 Advisory
1473 ));
1474 assert!(!confinement_unenforceable(
1476 SandboxKind::Landlock,
1477 &fs_write,
1478 Advisory
1479 ));
1480 assert!(!confinement_unenforceable(
1481 SandboxKind::Landlock,
1482 &fs_read,
1483 Advisory
1484 ));
1485
1486 assert!(!confinement_unenforceable(
1489 SandboxKind::None,
1490 &exec,
1491 Advisory
1492 ));
1493 assert!(confinement_unenforceable(SandboxKind::None, &exec, Kernel));
1494
1495 assert!(!confinement_unenforceable(
1497 SandboxKind::None,
1498 &Caveats::top(),
1499 Kernel
1500 ));
1501 }
1502
1503 #[test]
1507 fn fs_restricted_under_appcontainer_engages_the_launcher() {
1508 let fs = Caveats {
1509 fs_write: Scope::only(["/tmp/x".to_string()]),
1510 ..Caveats::top()
1511 };
1512 let governing = effective_sandbox_kind(SandboxKind::AppContainer, &fs);
1513 assert_eq!(
1514 governing,
1515 SandboxKind::AppContainer,
1516 "fs-only must engage AppContainer (ACL narrowing wired, #51)"
1517 );
1518 let report = enforcement_report(&fs, governing);
1520 assert_eq!(report.fs_write, Some(AxisEnforcement::Kernel));
1521 assert!(
1523 !confinement_unenforceable(governing, &fs, AxisEnforcement::Advisory),
1524 "fs-restricted AppContainer is enforceable (launcher wired)"
1525 );
1526 }
1527
1528 #[test]
1531 fn exec_deny_all_under_appcontainer_is_kernel() {
1532 let exec_denied = Caveats {
1533 exec: Scope::only([] as [String; 0]),
1534 ..Caveats::top()
1535 };
1536 let governing = effective_sandbox_kind(SandboxKind::AppContainer, &exec_denied);
1537 assert_eq!(
1538 governing,
1539 SandboxKind::AppContainer,
1540 "exec deny-all must engage AppContainer"
1541 );
1542 assert!(
1544 !confinement_unenforceable(governing, &exec_denied, AxisEnforcement::Advisory),
1545 "exec deny-all under AppContainer is enforceable (kernel-level block)"
1546 );
1547 let report = enforcement_report(&exec_denied, governing);
1548 assert_eq!(
1549 report.exec,
1550 Some(AxisEnforcement::Kernel),
1551 "exec deny-all must be Kernel under AppContainer"
1552 );
1553 }
1554
1555 #[test]
1560 fn trusted_worker_preserves_appcontainer_exec_deny_all() {
1561 let exec_denied = Caveats {
1562 exec: Scope::only([] as [String; 0]),
1563 ..Caveats::top()
1564 };
1565
1566 let mechanism = trusted_worker_mechanism_caveats(
1567 SandboxKind::AppContainer,
1568 &exec_denied,
1569 "this-path-is-not-used-by-appcontainer",
1570 )
1571 .expect("AppContainer mechanism caveats");
1572
1573 assert!(
1574 crate::sandbox::exec_fully_denied(&mechanism),
1575 "the launcher must still select --no-child-process"
1576 );
1577 assert_eq!(
1578 effective_sandbox_kind(SandboxKind::AppContainer, &mechanism),
1579 SandboxKind::AppContainer,
1580 "deny-all must still engage the AppContainer boundary"
1581 );
1582 assert_eq!(
1583 enforcement_report(&mechanism, SandboxKind::AppContainer).exec,
1584 Some(AxisEnforcement::Kernel),
1585 "the preserved mechanism matches the reported kernel guarantee"
1586 );
1587 }
1588
1589 #[test]
1592 fn trusted_worker_keeps_exec_allowance_for_allowlist_backends() {
1593 let exec_denied = Caveats {
1594 exec: Scope::only([] as [String; 0]),
1595 ..Caveats::top()
1596 };
1597 let current = std::env::current_exe()
1598 .expect("current executable")
1599 .canonicalize()
1600 .expect("canonical current executable")
1601 .to_string_lossy()
1602 .into_owned();
1603
1604 for kind in [SandboxKind::Landlock, SandboxKind::Seatbelt] {
1605 let mechanism = trusted_worker_mechanism_caveats(kind, &exec_denied, ¤t)
1606 .expect("allowlist mechanism caveats");
1607 assert!(
1608 matches!(&mechanism.exec, Scope::Only(programs) if programs.contains(¤t)),
1609 "{kind:?} must authorize the fixed worker executable"
1610 );
1611 }
1612 }
1613
1614 #[cfg(not(any(
1620 all(target_os = "linux", feature = "linux-landlock"),
1621 all(target_os = "macos", feature = "macos-seatbelt"),
1622 all(target_os = "windows", feature = "windows-appcontainer")
1623 )))]
1624 #[test]
1625 fn restrictive_write_refused_when_no_sandbox_available() {
1626 let cx = ctx(Caveats {
1627 exec: Scope::All,
1628 fs_write: Scope::only(["/tmp/allowed".to_string()]),
1629 ..Caveats::top()
1630 });
1631 let res = ConfinedCommand::new("true").spawn(&cx);
1632 assert!(
1633 matches!(res, Err(ToolError::Denied { .. })),
1634 "must fail closed when confinement is requested but unenforceable"
1635 );
1636 }
1637
1638 #[cfg(unix)]
1642 #[test]
1643 fn environment_is_scrubbed_to_the_granted_allow_list() {
1644 let env_bin = ["/usr/bin/env", "/bin/env"]
1645 .into_iter()
1646 .find(|p| Path::new(p).exists());
1647 let Some(env_bin) = env_bin else {
1648 eprintln!("skipping env-scrub test: no env(1) found");
1649 return;
1650 };
1651 let cx = ctx(Caveats {
1654 exec: Scope::only(["env".to_string()]),
1655 ..Caveats::top()
1656 });
1657 let spawned = ConfinedCommand::new(env_bin)
1658 .env("ALLOWED", "yes")
1659 .stdout(Stdio::piped())
1660 .spawn(&cx)
1661 .expect("spawn env");
1662 let out = spawned.child.wait_with_output().expect("wait");
1663 let text = String::from_utf8_lossy(&out.stdout);
1664 assert!(text.contains("ALLOWED=yes"), "granted var must be present");
1665 assert!(
1666 !text.contains("HOME="),
1667 "ambient parent env must NOT leak into the child: {text:?}"
1668 );
1669 }
1670}
1671
1672#[cfg(all(target_os = "linux", feature = "linux-landlock", test))]
1676mod landlock_child_tests {
1677 use super::*;
1678 use crate::{landlock_is_supported, Gate, Tool};
1679 use std::fs;
1680 use std::path::PathBuf;
1681 use std::sync::atomic::{AtomicU64, Ordering};
1682
1683 fn ctx(granted: Caveats) -> ToolContext {
1684 struct AnyTool;
1685 #[async_trait::async_trait]
1686 impl Tool for AnyTool {
1687 fn name(&self) -> &str {
1688 "any"
1689 }
1690 fn schema(&self) -> serde_json::Value {
1691 serde_json::json!({})
1692 }
1693 async fn invoke(
1694 &self,
1695 _a: serde_json::Value,
1696 _c: &ToolContext,
1697 ) -> ToolResult<serde_json::Value> {
1698 Ok(serde_json::Value::Null)
1699 }
1700 }
1701 Gate::new(0)
1702 .authorize(&AnyTool, &granted)
1703 .expect("authorize")
1704 }
1705
1706 fn unique_dir(tag: &str) -> PathBuf {
1707 static N: AtomicU64 = AtomicU64::new(0);
1708 let mut d = std::env::temp_dir();
1709 d.push(format!(
1710 "agent-bridle-spawn-{}-{}-{}",
1711 tag,
1712 std::process::id(),
1713 N.fetch_add(1, Ordering::Relaxed)
1714 ));
1715 fs::create_dir_all(&d).unwrap();
1716 d
1717 }
1718
1719 #[test]
1720 fn child_inherits_fs_write_domain_out_of_scope_denied_in_scope_allowed() {
1721 if !landlock_is_supported() {
1722 eprintln!("skipping: kernel lacks Landlock");
1723 return;
1724 }
1725 let touch = ["/usr/bin/touch", "/bin/touch"]
1726 .into_iter()
1727 .find(|p| std::path::Path::new(p).exists());
1728 let Some(touch) = touch else {
1729 eprintln!("skipping: no touch(1) found");
1730 return;
1731 };
1732
1733 let allowed = unique_dir("allowed");
1734 let forbidden = unique_dir("forbidden");
1735 let cx = ctx(Caveats {
1736 exec: Scope::only(["touch".to_string()]),
1737 fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
1738 ..Caveats::top()
1739 });
1740
1741 let mut out = ConfinedCommand::new(touch)
1743 .arg(forbidden.join("escape.txt"))
1744 .spawn(&cx)
1745 .expect("spawn");
1746 assert_eq!(out.sandbox_kind, SandboxKind::Landlock);
1747 let status = out.child.wait().expect("wait");
1748 assert!(
1749 !status.success(),
1750 "child write outside fs_write must be kernel-denied"
1751 );
1752 assert!(!forbidden.join("escape.txt").exists());
1753
1754 let mut ok = ConfinedCommand::new(touch)
1756 .arg(allowed.join("ok.txt"))
1757 .spawn(&cx)
1758 .expect("spawn");
1759 assert!(ok.child.wait().expect("wait").success());
1760 assert!(allowed.join("ok.txt").exists());
1761
1762 let _ = fs::remove_dir_all(&allowed);
1763 let _ = fs::remove_dir_all(&forbidden);
1764 }
1765
1766 #[test]
1771 fn confined_command_honors_sandbox_policy_base_read() {
1772 if !landlock_is_supported() {
1773 eprintln!("skipping: kernel lacks Landlock");
1774 return;
1775 }
1776 let cat = ["/usr/bin/cat", "/bin/cat"]
1777 .into_iter()
1778 .find(|p| std::path::Path::new(p).exists());
1779 let Some(cat) = cat else {
1780 eprintln!("skipping: no cat(1) found");
1781 return;
1782 };
1783
1784 let allowed = unique_dir("cfg-allowed");
1785 let extra = unique_dir("cfg-extra");
1786 fs::write(extra.join("data.txt"), b"configured").unwrap();
1787 let cx = ctx(Caveats {
1788 exec: Scope::only(["cat".to_string()]),
1789 fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
1790 ..Caveats::top()
1791 });
1792
1793 let mut denied = ConfinedCommand::new(cat)
1795 .arg(extra.join("data.txt"))
1796 .stdout(Stdio::null())
1797 .stderr(Stdio::null())
1798 .spawn(&cx)
1799 .expect("spawn");
1800 assert!(
1801 !denied.child.wait().expect("wait").success(),
1802 "default base read must deny the child reading the out-of-scope file"
1803 );
1804
1805 let mut base = SandboxPolicy::default().base_read_paths;
1807 base.extra.push(extra.to_string_lossy().into_owned());
1808 let policy = Arc::new(SandboxPolicy {
1809 base_read_paths: base,
1810 ..SandboxPolicy::default()
1811 });
1812 let mut ok = ConfinedCommand::new(cat)
1813 .arg(extra.join("data.txt"))
1814 .sandbox_policy(policy)
1815 .stdout(Stdio::null())
1816 .stderr(Stdio::null())
1817 .spawn(&cx)
1818 .expect("spawn");
1819 assert!(
1820 ok.child.wait().expect("wait").success(),
1821 "a config-widened base_read_paths must let the child read the extra file"
1822 );
1823
1824 let _ = fs::remove_dir_all(&allowed);
1825 let _ = fs::remove_dir_all(&extra);
1826 }
1827}
1828
1829#[cfg(all(target_os = "macos", feature = "macos-seatbelt", test))]
1833mod seatbelt_child_tests {
1834 use super::*;
1835 use crate::{seatbelt_is_supported, Gate, Tool};
1836 use std::fs;
1837 use std::path::PathBuf;
1838 use std::sync::atomic::{AtomicU64, Ordering};
1839
1840 fn ctx(granted: Caveats) -> ToolContext {
1841 struct AnyTool;
1842 #[async_trait::async_trait]
1843 impl Tool for AnyTool {
1844 fn name(&self) -> &str {
1845 "any"
1846 }
1847 fn schema(&self) -> serde_json::Value {
1848 serde_json::json!({})
1849 }
1850 async fn invoke(
1851 &self,
1852 _a: serde_json::Value,
1853 _c: &ToolContext,
1854 ) -> ToolResult<serde_json::Value> {
1855 Ok(serde_json::Value::Null)
1856 }
1857 }
1858 Gate::new(0)
1859 .authorize(&AnyTool, &granted)
1860 .expect("authorize")
1861 }
1862
1863 fn unique_dir(tag: &str) -> PathBuf {
1864 static N: AtomicU64 = AtomicU64::new(0);
1865 let mut d = std::env::temp_dir();
1866 d.push(format!(
1867 "agent-bridle-spawn-sb-{}-{}-{}",
1868 tag,
1869 std::process::id(),
1870 N.fetch_add(1, Ordering::Relaxed)
1871 ));
1872 fs::create_dir_all(&d).unwrap();
1873 d
1874 }
1875
1876 #[test]
1877 fn child_inherits_fs_write_domain_out_of_scope_denied_in_scope_allowed() {
1878 if !seatbelt_is_supported() {
1879 eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1880 return;
1881 }
1882 let allowed = unique_dir("allowed");
1883 let forbidden = unique_dir("forbidden");
1884 let cx = ctx(Caveats {
1885 exec: Scope::only(["/usr/bin/touch".to_string()]),
1888 fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
1889 ..Caveats::top()
1890 });
1891
1892 let mut out = ConfinedCommand::new("/usr/bin/touch")
1894 .arg(forbidden.join("escape.txt"))
1895 .spawn(&cx)
1896 .expect("spawn");
1897 assert_eq!(out.sandbox_kind, SandboxKind::Seatbelt);
1898 let status = out.child.wait().expect("wait");
1899 assert!(
1900 !status.success(),
1901 "child write outside fs_write must be kernel-denied"
1902 );
1903 assert!(!forbidden.join("escape.txt").exists());
1904
1905 let mut ok = ConfinedCommand::new("/usr/bin/touch")
1907 .arg(allowed.join("ok.txt"))
1908 .spawn(&cx)
1909 .expect("spawn");
1910 assert!(ok.child.wait().expect("wait").success());
1911 assert!(allowed.join("ok.txt").exists());
1912
1913 let _ = fs::remove_dir_all(&allowed);
1914 let _ = fs::remove_dir_all(&forbidden);
1915 }
1916
1917 #[test]
1923 fn top_grant_confines_nothing_reports_none() {
1924 if !seatbelt_is_supported() {
1925 eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1926 return;
1927 }
1928 let cx = ctx(Caveats::top());
1929 let child = ConfinedCommand::new("/usr/bin/true")
1930 .spawn(&cx)
1931 .expect("spawn");
1932 assert_eq!(
1933 child.sandbox_kind,
1934 SandboxKind::None,
1935 "nothing restricted => nothing confined => None, not the raw backend kind"
1936 );
1937 }
1938
1939 #[test]
1945 fn restricted_exec_engages_seatbelt() {
1946 if !seatbelt_is_supported() {
1947 eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1948 return;
1949 }
1950 let cx = ctx(Caveats {
1953 exec: Scope::only(["/usr/bin/true".to_string()]),
1954 ..Caveats::top()
1955 });
1956 let child = ConfinedCommand::new("/usr/bin/true")
1957 .spawn(&cx)
1958 .expect("spawn");
1959 assert_eq!(
1960 child.sandbox_kind,
1961 SandboxKind::Seatbelt,
1962 "a restricted exec axis is kernel-confined by process-exec* (ADR 0014)"
1963 );
1964 }
1965}