1use std::collections::BTreeMap;
34use std::io::{self, Read, Write};
35use std::os::unix::net::UnixStream;
36use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, OnceLock, mpsc};
38use std::time::{Duration, Instant};
39
40pub(crate) const EXEC_FD_FLAG: &str = "--protected-exec-fd";
42pub(crate) const CHILD_EXEC_FD: i32 = 4;
45
46pub type ShellRunner = fn(
51 &str,
52 &str,
53 &std::collections::HashMap<String, String>,
54 Option<u64>,
55 Option<&AtomicBool>,
56 bool,
57 Option<&Mutex<String>>,
58) -> (String, i32);
59
60static SHELL_RUNNER: OnceLock<ShellRunner> = OnceLock::new();
61
62pub fn register_shell_runner(runner: ShellRunner) {
65 let _ = SHELL_RUNNER.set(runner);
66}
67
68const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
69const LAUNCH_PROFILE_HEADER: &str = "(version 1)\n(allow default)\n";
71
72const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
75const MAX_INFLIGHT: usize = 8;
79const MAX_REQUEST_WALL: Duration = Duration::from_hours(4);
82const LIVE_INTERVAL: Duration = Duration::from_millis(250);
84const CLIENT_TICK: Duration = Duration::from_millis(50);
86const CHANNEL_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
87const TEARDOWN_GRACE: Duration = Duration::from_secs(5);
89
90const PROTOCOL_VERSION: u8 = 1;
91
92#[derive(serde::Serialize, serde::Deserialize)]
97#[serde(deny_unknown_fields)]
98struct Request {
99 version: u8,
100 id: u64,
101 body: Body,
102}
103
104#[derive(serde::Serialize, serde::Deserialize)]
105#[serde(deny_unknown_fields, rename_all = "snake_case")]
106enum Body {
107 Shell {
110 command: String,
111 cwd: String,
112 env: BTreeMap<String, String>,
113 timeout_ms: Option<u64>,
114 idle_keyed: bool,
115 },
116 Program {
119 constrained: bool,
120 read_paths: Vec<String>,
121 program: String,
122 args: Vec<String>,
123 env: Vec<(String, String)>,
124 timeout_secs: u64,
125 cwd: Option<String>,
126 },
127 Cancel,
129}
130
131#[derive(serde::Serialize, serde::Deserialize)]
132#[serde(deny_unknown_fields)]
133struct Envelope {
134 version: u8,
135 id: u64,
136 event: Event,
137}
138
139#[derive(serde::Serialize, serde::Deserialize, Debug)]
140#[serde(deny_unknown_fields, rename_all = "snake_case")]
141pub enum Event {
142 Live {
144 text: String,
145 },
146 ShellExit {
147 text: String,
148 code: i32,
149 },
150 ProgramExit {
151 stdout: String,
152 stderr: String,
153 code: i32,
154 },
155 Failed {
158 message: String,
159 },
160}
161
162fn write_frame(stream: &mut UnixStream, payload: &[u8]) -> io::Result<()> {
163 if payload.len() > MAX_FRAME_BYTES {
164 return Err(io::Error::other("frame exceeds the channel limit"));
165 }
166 stream.write_all(&(payload.len() as u32).to_be_bytes())?;
167 stream.write_all(payload)?;
168 stream.flush()
169}
170
171fn read_frame(stream: &mut UnixStream) -> io::Result<Option<Vec<u8>>> {
175 let mut header = [0_u8; 4];
176 match stream.read_exact(&mut header) {
177 Ok(()) => {}
178 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
179 Err(error) => return Err(error),
180 }
181 let len = u32::from_be_bytes(header) as usize;
182 if len > MAX_FRAME_BYTES {
183 return Err(io::Error::other("frame exceeds the channel limit"));
184 }
185 let mut body = vec![0_u8; len];
186 match stream.read_exact(&mut body) {
187 Ok(()) => Ok(Some(body)),
188 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(None),
189 Err(error) => Err(error),
190 }
191}
192
193#[derive(Clone)]
201pub struct LaunchProfile {
202 shell: String,
204 denials: String,
206}
207
208impl LaunchProfile {
209 pub(crate) fn from_pinned(control_profile: &str) -> Result<Self, String> {
212 let prefix = crate::core::protected_store::protected_child_prefix()?
213 .ok_or("supervised execution requires a protected session")?;
214 let mut shell = match prefix.as_slice() {
215 [program, flag, profile] if program == SANDBOX_EXEC && flag == "-p" => profile.clone(),
216 _ => return Err("unexpected protected launch rules".into()),
217 };
218 if !shell.starts_with(LAUNCH_PROFILE_HEADER) {
219 return Err("protected launch rules have an unexpected header".into());
220 }
221 let controls = control_profile
224 .strip_prefix(LAUNCH_PROFILE_HEADER)
225 .ok_or("protected control profile has an unexpected header")?;
226 for line in controls.lines().filter(|line| !line.is_empty()) {
227 if !line.starts_with("(deny ")
228 && line != "(allow network-outbound (literal \"/private/var/run/mDNSResponder\"))"
229 {
230 return Err("protected control profile contains an unsupported grant".into());
231 }
232 }
233 shell.push_str(controls);
234 if shell.len() > 128 * 1024 {
235 return Err("combined protected launch profile exceeds the limit".into());
236 }
237 let mut denials = String::new();
238 for line in shell
239 .strip_prefix(LAUNCH_PROFILE_HEADER)
240 .ok_or("protected launch profile has an unexpected header")?
241 .lines()
242 .filter(|line| !line.is_empty())
243 {
244 if line.starts_with("(deny ") && line.ends_with(')') {
245 denials.push_str(line);
246 denials.push('\n');
247 } else if !line.starts_with("(allow ") || !line.ends_with(')') {
248 return Err("protected launch profile contains an unsupported rule".into());
249 }
250 }
251 if denials.is_empty() {
252 return Err("protected launch rules are not a pure denial set".into());
253 }
254 Ok(Self { shell, denials })
255 }
256
257 pub(crate) fn shell_profile(&self) -> &str {
258 &self.shell
259 }
260
261 fn compose(&self, read_paths: &[String], program: &str) -> Result<String, String> {
265 if read_paths.len() > 64
266 || read_paths
267 .iter()
268 .map(String::as_str)
269 .chain(std::iter::once(program))
270 .any(|path| {
271 !std::path::Path::new(path).is_absolute() || path.chars().any(char::is_control)
272 })
273 {
274 return Err("supervised program paths must be bounded and absolute".into());
275 }
276 let paths: Vec<&std::path::Path> = read_paths.iter().map(std::path::Path::new).collect();
277 let requested = crate::core::sandbox_seatbelt::seatbelt_profile(&paths, program);
278 if requested.len() + self.denials.len() > MAX_FRAME_BYTES {
279 return Err("supervised program profile exceeds the channel limit".into());
280 }
281 let mut composed = String::with_capacity(requested.len() + self.denials.len() + 1);
282 composed.push_str(&requested);
283 if !composed.ends_with('\n') {
284 composed.push('\n');
285 }
286 composed.push_str(&self.denials);
287 Ok(composed)
288 }
289
290 #[cfg(any(test, all(feature = "test-support", target_os = "macos")))]
291 fn synthetic(denials: &str) -> Self {
292 Self {
293 shell: format!("{LAUNCH_PROFILE_HEADER}{denials}"),
294 denials: denials.to_string(),
295 }
296 }
297}
298
299thread_local! {
304 static BROKER_PROFILE: std::cell::RefCell<Option<String>> = const {
308 std::cell::RefCell::new(None)
309 };
310}
311
312fn broker_profile() -> Option<String> {
313 BROKER_PROFILE.with(|slot| slot.borrow().clone())
314}
315
316fn with_broker_profile<T>(profile: String, run: impl FnOnce() -> T) -> T {
317 BROKER_PROFILE.with(|slot| *slot.borrow_mut() = Some(profile));
318 let result = run();
319 BROKER_PROFILE.with(|slot| *slot.borrow_mut() = None);
320 result
321}
322
323#[cfg(all(any(test, feature = "test-support"), target_os = "macos"))]
324#[allow(clippy::unwrap_used)]
325pub fn with_test_supervisor<T>(run: impl FnOnce() -> T) -> T {
326 let prefix = crate::core::protected_store::protected_child_prefix()
327 .unwrap()
328 .unwrap();
329 with_broker_profile(prefix[2].clone(), run)
330}
331
332pub enum Mode {
333 Direct,
335 Sandboxed(String),
338 Brokered(Arc<Client>),
340}
341
342pub fn launch_mode() -> Result<Mode, String> {
348 if let Some(profile) = broker_profile() {
349 return Ok(Mode::Sandboxed(profile));
350 }
351 if let Some(client) = client() {
352 if client.is_closed() {
353 return Err(
354 "the protected execution channel closed; restart `lean-ctx codex-protected`".into(),
355 );
356 }
357 return Ok(Mode::Brokered(client));
358 }
359 if crate::core::protected_store::protected_child_prefix()?.is_some() {
360 return Err(
361 "protected session without a supervised execution channel; restart \
362 `lean-ctx codex-protected`"
363 .into(),
364 );
365 }
366 Ok(Mode::Direct)
367}
368
369pub fn sandbox_prefix(profile: &str) -> Vec<String> {
371 vec![
372 SANDBOX_EXEC.to_string(),
373 "-p".to_string(),
374 profile.to_string(),
375 ]
376}
377
378static CLIENT: OnceLock<Arc<Client>> = OnceLock::new();
383
384pub struct Client {
385 writer: Mutex<UnixStream>,
386 inflight: Mutex<std::collections::HashMap<u64, mpsc::SyncSender<Event>>>,
387 next_id: AtomicU64,
388 closed: Arc<AtomicBool>,
389}
390
391pub(crate) fn client() -> Option<Arc<Client>> {
392 CLIENT.get().cloned()
393}
394
395pub(crate) fn install_client(fd: i32) -> Result<(), String> {
402 use std::os::fd::FromRawFd;
403
404 if fd <= 2 {
405 return Err("invalid protected execution descriptor".into());
406 }
407 let mut kind: libc::c_int = 0;
408 let mut len = std::mem::size_of_val(&kind) as libc::socklen_t;
409 if unsafe {
411 libc::getsockopt(
412 fd,
413 libc::SOL_SOCKET,
414 libc::SO_TYPE,
415 (&raw mut kind).cast(),
416 &raw mut len,
417 )
418 } != 0
419 || kind != libc::SOCK_STREAM
420 {
421 return Err("supervised execution requires an anonymous stream socket".into());
422 }
423 let stream = unsafe { UnixStream::from_raw_fd(fd) };
426 if !stream
427 .local_addr()
428 .is_ok_and(|address| address.is_unnamed())
429 || !stream.peer_addr().is_ok_and(|address| address.is_unnamed())
430 {
431 return Err("supervised execution requires an anonymous connected socket pair".into());
432 }
433 if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
435 return Err("cannot seal the protected execution descriptor".into());
436 }
437 adopt(stream)
438}
439
440fn adopt(stream: UnixStream) -> Result<(), String> {
441 stream
442 .set_write_timeout(Some(CHANNEL_WRITE_TIMEOUT))
443 .map_err(|_| "cannot bound execution channel writes")?;
444 let reader = stream
445 .try_clone()
446 .map_err(|_| "cannot bind the protected execution channel")?;
447 let closed = Arc::new(AtomicBool::new(false));
448 let client = Arc::new(Client {
449 writer: Mutex::new(stream),
450 inflight: Mutex::new(std::collections::HashMap::new()),
451 next_id: AtomicU64::new(1),
452 closed: Arc::clone(&closed),
453 });
454 let pump = Arc::clone(&client);
455 std::thread::Builder::new()
456 .name("leanctx-exec-client".into())
457 .spawn(move || pump.pump(reader))
458 .map_err(|_| "cannot start the protected execution reader")?;
459 CLIENT
460 .set(client)
461 .map_err(|_| "the protected execution channel is already bound".to_string())
462}
463
464impl Client {
465 fn is_closed(&self) -> bool {
466 self.closed.load(Ordering::Acquire)
467 }
468
469 fn pump(&self, mut reader: UnixStream) {
473 while let Ok(Some(frame)) = read_frame(&mut reader) {
474 match serde_json::from_slice::<Envelope>(&frame) {
475 Ok(envelope) if envelope.version == PROTOCOL_VERSION => {
476 let sender = self
477 .inflight
478 .lock()
479 .ok()
480 .and_then(|table| table.get(&envelope.id).cloned());
481 if let Some(sender) = sender {
482 let advisory = matches!(envelope.event, Event::Live { .. });
483 if matches!(
484 sender.try_send(envelope.event),
485 Err(mpsc::TrySendError::Full(_))
486 ) && !advisory
487 {
488 break;
489 }
490 }
491 }
492 _ => break,
493 }
494 }
495 self.closed.store(true, Ordering::Release);
496 let _ = reader.shutdown(std::net::Shutdown::Both);
497 if let Ok(mut table) = self.inflight.lock() {
498 table.clear();
499 }
500 }
501
502 fn begin(&self) -> Result<(u64, mpsc::Receiver<Event>), String> {
503 if self.is_closed() {
504 return Err("the protected execution channel closed".into());
505 }
506 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
507 let (sender, receiver) = mpsc::sync_channel(4);
508 let mut table = self
509 .inflight
510 .lock()
511 .map_err(|_| "the protected execution channel is unusable")?;
512 if self.is_closed() {
513 return Err("the protected execution channel closed".into());
514 }
515 if table.len() >= MAX_INFLIGHT {
516 return Err("too many concurrent supervised commands".into());
517 }
518 table.insert(id, sender);
519 Ok((id, receiver))
520 }
521
522 fn finish(&self, id: u64) {
523 if let Ok(mut table) = self.inflight.lock() {
524 table.remove(&id);
525 }
526 }
527
528 fn send(&self, id: u64, body: Body) -> Result<(), String> {
529 let frame = serde_json::to_vec(&Request {
530 version: PROTOCOL_VERSION,
531 id,
532 body,
533 })
534 .map_err(|_| "cannot encode a supervised request")?;
535 let mut writer = self
536 .writer
537 .lock()
538 .map_err(|_| "the protected execution channel is unusable")?;
539 write_frame(&mut writer, &frame).map_err(|_| {
540 self.closed.store(true, Ordering::Release);
541 let _ = writer.shutdown(std::net::Shutdown::Both);
542 "the protected execution channel closed".to_string()
543 })
544 }
545
546 pub fn shell(
550 &self,
551 command: &str,
552 cwd: &str,
553 env: &std::collections::HashMap<String, String>,
554 timeout_ms: Option<u64>,
555 cancel: Option<&AtomicBool>,
556 idle_keyed: bool,
557 live: Option<&Mutex<String>>,
558 ) -> (String, i32) {
559 let body = Body::Shell {
560 command: command.to_string(),
561 cwd: cwd.to_string(),
562 env: env
563 .iter()
564 .map(|(key, value)| (key.clone(), value.clone()))
565 .collect(),
566 timeout_ms,
567 idle_keyed,
568 };
569 match self.request(body, cancel, live) {
570 Ok(Event::ShellExit { text, code }) => (text, code),
571 Ok(Event::Failed { message }) => (format!("ERROR: {message}"), 1),
572 Ok(_) => ("ERROR: unexpected supervised execution response".into(), 1),
573 Err(error) => (format!("ERROR: {error}"), 1),
574 }
575 }
576
577 pub(crate) fn program(
580 &self,
581 read_paths: &[&std::path::Path],
582 program: &str,
583 args: &[&str],
584 env: &[(String, String)],
585 timeout_secs: u64,
586 cwd: Option<&std::path::Path>,
587 ) -> Result<(String, String, i32), String> {
588 self.program_with_boundary(read_paths, program, args, env, timeout_secs, cwd, true)
589 }
590
591 pub fn session_program(
593 &self,
594 program: &str,
595 args: &[&str],
596 env: &[(String, String)],
597 timeout_secs: u64,
598 cwd: Option<&std::path::Path>,
599 ) -> Result<(String, String, i32), String> {
600 self.program_with_boundary(&[], program, args, env, timeout_secs, cwd, false)
601 }
602
603 #[allow(clippy::too_many_arguments)]
604 fn program_with_boundary(
605 &self,
606 read_paths: &[&std::path::Path],
607 program: &str,
608 args: &[&str],
609 env: &[(String, String)],
610 timeout_secs: u64,
611 cwd: Option<&std::path::Path>,
612 constrained: bool,
613 ) -> Result<(String, String, i32), String> {
614 let body = Body::Program {
615 constrained,
616 read_paths: read_paths
617 .iter()
618 .map(|path| {
619 path.to_str()
620 .map(str::to_owned)
621 .ok_or("program read path is not UTF-8".to_string())
622 })
623 .collect::<Result<_, _>>()?,
624 program: program.to_string(),
625 args: args.iter().map(|arg| (*arg).to_string()).collect(),
626 env: env.to_vec(),
627 timeout_secs,
628 cwd: cwd
629 .map(|dir| {
630 dir.to_str()
631 .map(str::to_string)
632 .ok_or_else(|| "working directory is not valid UTF-8".to_string())
633 })
634 .transpose()?,
635 };
636 match self.request(body, None, None)? {
637 Event::ProgramExit {
638 stdout,
639 stderr,
640 code,
641 } => Ok((stdout, stderr, code)),
642 Event::Failed { message } => Err(message),
643 _ => Err("unexpected supervised execution response".into()),
644 }
645 }
646
647 fn request(
648 &self,
649 body: Body,
650 cancel: Option<&AtomicBool>,
651 live: Option<&Mutex<String>>,
652 ) -> Result<Event, String> {
653 let (id, events) = self.begin()?;
654 let outcome = self.wait(id, body, cancel, live, &events);
655 self.finish(id);
656 outcome
657 }
658
659 fn wait(
660 &self,
661 id: u64,
662 body: Body,
663 cancel: Option<&AtomicBool>,
664 live: Option<&Mutex<String>>,
665 events: &mpsc::Receiver<Event>,
666 ) -> Result<Event, String> {
667 self.send(id, body)?;
668 let mut cancelled = false;
669 let deadline = Instant::now() + MAX_REQUEST_WALL + TEARDOWN_GRACE;
670 loop {
671 if Instant::now() >= deadline {
672 let _ = self.send(id, Body::Cancel);
673 return Err("supervised command exceeded its absolute lifetime".into());
674 }
675 match events.recv_timeout(CLIENT_TICK) {
676 Ok(Event::Live { text }) => {
677 if let Some(live) = live
678 && let Ok(mut guard) = live.try_lock()
679 {
680 *guard = text;
681 }
682 }
683 Ok(event) => return Ok(event),
684 Err(mpsc::RecvTimeoutError::Timeout) => {
685 if self.is_closed() {
686 return Err("the protected execution channel closed".into());
687 }
688 if !cancelled
689 && cancel.is_some_and(|signal| signal.load(Ordering::Acquire))
690 && self.send(id, Body::Cancel).is_ok()
691 {
692 cancelled = true;
693 }
694 }
695 Err(mpsc::RecvTimeoutError::Disconnected) => {
696 return Err("the protected execution channel closed".into());
697 }
698 }
699 }
700 }
701}
702
703struct Supervisor {
708 writer: Mutex<UnixStream>,
709 profile: LaunchProfile,
710 running: Mutex<std::collections::HashMap<u64, Arc<AtomicBool>>>,
711}
712
713pub fn serve(stream: UnixStream, profile: LaunchProfile) -> Result<(), String> {
717 stream
718 .set_write_timeout(Some(CHANNEL_WRITE_TIMEOUT))
719 .map_err(|_| "cannot bound supervisor channel writes")?;
720 let mut reader = stream
721 .try_clone()
722 .map_err(|_| "cannot bind the supervised execution channel")?;
723 let supervisor = Arc::new(Supervisor {
724 writer: Mutex::new(stream),
725 profile,
726 running: Mutex::new(std::collections::HashMap::new()),
727 });
728 let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
729 while let Ok(Some(frame)) = read_frame(&mut reader) {
731 let Ok(request): Result<Request, _> = serde_json::from_slice(&frame) else {
732 supervisor.fail(0, "malformed supervised request");
733 break;
734 };
735 if request.version != PROTOCOL_VERSION {
736 supervisor.fail(request.id, "unsupported supervised protocol version");
737 continue;
738 }
739 if matches!(request.body, Body::Cancel) {
740 supervisor.cancel(request.id);
741 continue;
742 }
743 workers.retain(|worker| !worker.is_finished());
744 if workers.len() >= MAX_INFLIGHT {
745 supervisor.fail(request.id, "too many concurrent supervised commands");
746 continue;
747 }
748 let worker = Arc::clone(&supervisor);
749 let id = request.id;
750 let body = request.body;
751 let cancel = Arc::new(AtomicBool::new(false));
752 {
753 let mut running = supervisor
754 .running
755 .lock()
756 .map_err(|_| "supervisor state unavailable")?;
757 if running.contains_key(&id) {
758 break;
759 }
760 running.insert(id, Arc::clone(&cancel));
761 }
762 if let Ok(handle) = std::thread::Builder::new()
763 .name("leanctx-exec-worker".into())
764 .spawn(move || worker.run(id, body, &cancel))
765 {
766 workers.push(handle);
767 } else {
768 if let Ok(mut running) = supervisor.running.lock() {
769 running.remove(&id);
770 }
771 supervisor.fail(id, "cannot start a supervised command");
772 }
773 }
774 let _ = reader.shutdown(std::net::Shutdown::Both);
775 supervisor.shutdown();
776 let deadline = Instant::now() + TEARDOWN_GRACE;
777 for worker in workers {
778 while !worker.is_finished() && Instant::now() < deadline {
779 std::thread::sleep(CLIENT_TICK);
780 }
781 if !worker.is_finished() {
782 break;
783 }
784 let _ = worker.join();
785 }
786 Ok(())
787}
788
789impl Supervisor {
790 fn emit(&self, id: u64, event: Event) {
791 let Ok(mut frame) = serde_json::to_vec(&Envelope {
792 version: PROTOCOL_VERSION,
793 id,
794 event,
795 }) else {
796 return;
797 };
798 if frame.len() > MAX_FRAME_BYTES {
799 frame = serde_json::to_vec(&Envelope {
800 version: PROTOCOL_VERSION,
801 id,
802 event: Event::Failed {
803 message: "supervised output exceeds the channel limit".into(),
804 },
805 })
806 .expect("fixed failure envelope");
807 }
808 if let Ok(mut writer) = self.writer.lock() {
809 if write_frame(&mut writer, &frame).is_err() {
810 let _ = writer.shutdown(std::net::Shutdown::Both);
811 self.shutdown();
812 }
813 }
814 }
815
816 fn fail(&self, id: u64, message: &str) {
817 self.emit(
818 id,
819 Event::Failed {
820 message: message.to_string(),
821 },
822 );
823 }
824
825 fn cancel(&self, id: u64) {
826 if let Ok(table) = self.running.lock()
827 && let Some(signal) = table.get(&id)
828 {
829 signal.store(true, Ordering::Release);
830 }
831 }
832
833 fn shutdown(&self) {
836 if let Ok(table) = self.running.lock() {
837 for signal in table.values() {
838 signal.store(true, Ordering::Release);
839 }
840 }
841 }
842
843 fn run(self: Arc<Self>, id: u64, body: Body, cancel: &Arc<AtomicBool>) {
844 let watchdog = {
847 let cancel = Arc::clone(cancel);
848 let (done, finished) = mpsc::sync_channel(1);
849 let handle = std::thread::Builder::new()
850 .name("leanctx-exec-watchdog".into())
851 .spawn(move || {
852 if matches!(
853 finished.recv_timeout(MAX_REQUEST_WALL),
854 Err(mpsc::RecvTimeoutError::Timeout)
855 ) {
856 cancel.store(true, Ordering::Release);
857 }
858 })
859 .ok();
860 (done, handle)
861 };
862
863 let event = if watchdog.1.is_none() {
864 Event::Failed {
865 message: "cannot bound supervised command lifetime".into(),
866 }
867 } else {
868 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match body {
869 Body::Shell {
870 command,
871 cwd,
872 env,
873 timeout_ms,
874 idle_keyed,
875 } => self.run_shell(id, &command, &cwd, env, timeout_ms, idle_keyed, cancel),
876 Body::Program {
877 constrained,
878 read_paths,
879 program,
880 args,
881 env,
882 timeout_secs,
883 cwd,
884 } => self.run_program(
885 constrained,
886 &read_paths,
887 &program,
888 &args,
889 &env,
890 timeout_secs,
891 cwd,
892 cancel,
893 ),
894 Body::Cancel => Event::Failed {
895 message: "cancellation is not a command".into(),
896 },
897 }))
898 .unwrap_or_else(|_| {
899 cancel.store(true, Ordering::Release);
900 Event::Failed {
901 message: "supervised command ended without a result".into(),
902 }
903 })
904 };
905
906 let _ = watchdog.0.send(());
907 if let Some(handle) = watchdog.1 {
908 let _ = handle.join();
909 }
910 if let Ok(mut table) = self.running.lock() {
911 table.remove(&id);
912 }
913 self.emit(id, event);
914 }
915
916 #[allow(clippy::too_many_arguments)]
917 fn run_shell(
918 self: &Arc<Self>,
919 id: u64,
920 command: &str,
921 cwd: &str,
922 env: BTreeMap<String, String>,
923 timeout_ms: Option<u64>,
924 idle_keyed: bool,
925 cancel: &Arc<AtomicBool>,
926 ) -> Event {
927 let Some(runner) = SHELL_RUNNER.get().copied() else {
929 return Event::Failed {
930 message: "no shell executor is registered with the protected supervisor".into(),
931 };
932 };
933 let extra: std::collections::HashMap<String, String> = env.into_iter().collect();
934 let live = Arc::new(Mutex::new(String::new()));
935 let (done, finished) = mpsc::sync_channel(1);
936 let mirror = {
937 let supervisor = Arc::clone(self);
938 let live = Arc::clone(&live);
939 std::thread::Builder::new()
940 .name("leanctx-exec-live".into())
941 .spawn(move || supervisor.mirror(id, &live, &finished))
942 .ok()
943 };
944 let profile = self.profile.shell.clone();
947 let (text, code) = with_broker_profile(profile, || {
948 runner(
949 command,
950 cwd,
951 &extra,
952 timeout_ms.map(|ms| ms.min(MAX_REQUEST_WALL.as_millis() as u64)),
953 Some(cancel.as_ref()),
954 idle_keyed,
955 Some(live.as_ref()),
956 )
957 });
958 let _ = done.send(());
959 if let Some(handle) = mirror {
960 let _ = handle.join();
961 }
962 Event::ShellExit { text, code }
963 }
964
965 #[allow(clippy::too_many_arguments)]
966 fn run_program(
967 &self,
968 constrained: bool,
969 read_paths: &[String],
970 program: &str,
971 args: &[String],
972 env: &[(String, String)],
973 timeout_secs: u64,
974 cwd: Option<String>,
975 cancel: &Arc<AtomicBool>,
976 ) -> Event {
977 let mut composed = match self.profile.compose(read_paths, program) {
978 Ok(composed) => composed,
979 Err(message) => return Event::Failed { message },
980 };
981 if !constrained {
982 composed.clone_from(&self.profile.shell);
984 }
985 let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
986 let directory = cwd.map(std::path::PathBuf::from);
987 match crate::core::sandbox_seatbelt::execute_with_profile(
988 &composed,
989 program,
990 &borrowed,
991 env,
992 timeout_secs.min(MAX_REQUEST_WALL.as_secs()),
993 directory.as_deref(),
994 Some(cancel.as_ref()),
995 ) {
996 Ok((stdout, stderr, code)) => Event::ProgramExit {
997 stdout,
998 stderr,
999 code,
1000 },
1001 Err(message) => Event::Failed { message },
1002 }
1003 }
1004
1005 fn mirror(&self, id: u64, live: &Mutex<String>, done: &mpsc::Receiver<()>) {
1010 let cap = crate::core::limits::max_shell_bytes().min(2 * 1024 * 1024);
1011 let mut last = String::new();
1012 while matches!(
1013 done.recv_timeout(LIVE_INTERVAL),
1014 Err(mpsc::RecvTimeoutError::Timeout)
1015 ) {
1016 let Ok(guard) = live.lock() else { return };
1017 let snapshot = guard.clone();
1018 drop(guard);
1019 if snapshot == last {
1020 continue;
1021 }
1022 last.clone_from(&snapshot);
1023 let bounded = if snapshot.len() > cap {
1024 let mut start = snapshot.len() - cap;
1025 while start < snapshot.len() && !snapshot.is_char_boundary(start) {
1026 start += 1;
1027 }
1028 snapshot[start..].to_string()
1029 } else {
1030 snapshot
1031 };
1032 self.emit(id, Event::Live { text: bounded });
1033 }
1034 }
1035}
1036
1037#[cfg(all(any(test, feature = "test-support"), target_os = "macos"))]
1042#[allow(clippy::unwrap_used)]
1043pub fn supervised_for_test<T>(denials: &str, exercise: impl FnOnce(&Client) -> T) -> T {
1044 let (supervisor_end, client_end) = UnixStream::pair().expect("socket pair");
1045 let profile = LaunchProfile::synthetic(denials);
1046 let server = std::thread::spawn(move || serve(supervisor_end, profile));
1047 let reader = client_end.try_clone().expect("clone");
1048 let closed = Arc::new(AtomicBool::new(false));
1049 let client = Arc::new(Client {
1050 writer: Mutex::new(client_end),
1051 inflight: Mutex::new(std::collections::HashMap::new()),
1052 next_id: AtomicU64::new(1),
1053 closed: Arc::clone(&closed),
1054 });
1055 let pump = Arc::clone(&client);
1056 let reader_thread = std::thread::spawn(move || pump.pump(reader));
1057 let result = exercise(&client);
1058 client
1060 .writer
1061 .lock()
1062 .unwrap()
1063 .shutdown(std::net::Shutdown::Both)
1064 .unwrap();
1065 drop(client);
1066 let _ = server.join();
1067 let _ = reader_thread.join();
1068 result
1069}
1070
1071#[cfg(test)]
1076mod tests {
1077 use super::*;
1078
1079 fn pair() -> (UnixStream, UnixStream) {
1080 UnixStream::pair().expect("socket pair")
1081 }
1082
1083 #[test]
1084 fn frames_round_trip_and_reject_oversize_and_truncation() {
1085 let (mut read, mut write) = pair();
1086 write_frame(&mut write, b"payload").unwrap();
1087 drop(write);
1088 assert_eq!(read_frame(&mut read).unwrap().unwrap(), b"payload");
1089 assert!(read_frame(&mut read).unwrap().is_none());
1090
1091 let (mut read, mut write) = pair();
1092 write
1095 .write_all(&((MAX_FRAME_BYTES as u32) + 1).to_be_bytes())
1096 .unwrap();
1097 drop(write);
1098 assert!(read_frame(&mut read).is_err());
1099
1100 let (mut read, mut write) = pair();
1101 write.write_all(&64_u32.to_be_bytes()).unwrap();
1102 write.write_all(b"short").unwrap();
1103 drop(write);
1104 assert!(read_frame(&mut read).unwrap().is_none());
1105
1106 let (_read, mut write) = pair();
1107 assert!(write_frame(&mut write, &vec![0_u8; MAX_FRAME_BYTES + 1]).is_err());
1108 }
1109
1110 #[test]
1111 fn a_program_profile_may_only_narrow() {
1112 let launch = LaunchProfile::synthetic("(deny file-read* (subpath \"/store\"))\n");
1113 let composed = launch
1114 .compose(&["/store".into()], "/bin/cat")
1115 .expect("composed");
1116 assert!(composed.starts_with("(version 1)\n(deny default)"));
1117 assert!(
1118 composed.find("(deny file-read*") > composed.find("(allow file-read*"),
1119 "the launch denials must come last: {composed}"
1120 );
1121 assert!(composed.ends_with(&launch.denials));
1122 assert!(launch.compose(&["relative".into()], "/bin/cat").is_err());
1123 assert!(launch.compose(&["/path\nrule".into()], "/bin/cat").is_err());
1124 assert!(
1125 launch
1126 .compose(&vec!["/store".into(); 65], "/bin/cat")
1127 .is_err()
1128 );
1129 assert!(launch.compose(&[], "cat").is_err());
1130 let quoted = launch
1131 .compose(&["/a\") (allow default) (\"b".into()], "/bin/cat")
1132 .unwrap();
1133 assert!(quoted.contains("/a\\\") (allow default) (\\\"b"));
1134 let legacy = serde_json::json!({"version":1,"id":1,"body":{"program":{
1135 "profile":"(version 1)\n(allow default)","read_paths":[],
1136 "program":"/bin/cat","args":[],"env":[],"timeout_secs":10,"cwd":null
1137 }}});
1138 assert!(serde_json::from_value::<Request>(legacy).is_err());
1139 }
1140
1141 #[cfg(target_os = "macos")]
1142 #[test]
1143 fn combined_shell_retains_controls_and_refuses_new_grants() {
1144 let _lock = crate::core::data_dir::test_env_lock();
1145 let isolation = crate::core::data_dir::isolated_data_dir();
1146 let _session =
1147 crate::core::protected_store::pin_synthetic_session(isolation.path()).unwrap();
1148 let control = "(deny file-read* (literal \"/synthetic-control\"))\n";
1149 let combined =
1150 LaunchProfile::from_pinned(&format!("{LAUNCH_PROFILE_HEADER}{control}")).unwrap();
1151 assert!(combined.shell.ends_with(control));
1152 assert!(
1153 combined
1154 .compose(&["/".into()], "/bin/cat")
1155 .unwrap()
1156 .ends_with(control)
1157 );
1158 assert!(
1159 LaunchProfile::from_pinned(&format!("{LAUNCH_PROFILE_HEADER}(allow file-read*)\n"))
1160 .is_err()
1161 );
1162 assert!(
1163 LaunchProfile::from_pinned(&format!("{LAUNCH_PROFILE_HEADER} {control}")).is_err()
1164 );
1165 }
1166
1167 #[test]
1168 fn a_community_session_runs_children_directly() {
1169 let _lock = crate::core::data_dir::test_env_lock();
1170 crate::core::protected_store::unpin_synthetic_session();
1171 assert!(matches!(launch_mode().unwrap(), Mode::Direct));
1172 }
1173
1174 #[test]
1175 #[cfg(target_os = "macos")]
1176 fn a_protected_session_without_a_channel_runs_nothing() {
1177 let _lock = crate::core::data_dir::test_env_lock();
1178 let isolation = crate::core::data_dir::isolated_data_dir();
1179 let _session = crate::core::protected_store::pin_synthetic_session(isolation.path())
1180 .expect("pinned rules");
1181 match launch_mode() {
1184 Err(message) => assert!(
1185 message.contains("supervised execution channel"),
1186 "{message}"
1187 ),
1188 Ok(_) => panic!("a protected session must not fall back to an unsupervised child"),
1189 }
1190 }
1191
1192 #[test]
1193 fn a_supervisor_thread_takes_the_broker_private_path() {
1194 let _lock = crate::core::data_dir::test_env_lock();
1195 crate::core::protected_store::unpin_synthetic_session();
1196 let profile = format!("{LAUNCH_PROFILE_HEADER}(deny file-read* (subpath \"/store\"))\n");
1197 with_broker_profile(profile.clone(), || match launch_mode().unwrap() {
1198 Mode::Sandboxed(fixed) => {
1199 assert_eq!(fixed, profile);
1200 assert_eq!(
1201 sandbox_prefix(&fixed),
1202 vec![SANDBOX_EXEC.to_string(), "-p".to_string(), profile.clone()]
1203 );
1204 }
1205 _ => panic!("a supervisor worker must apply the fixed profile itself"),
1206 });
1207 assert!(broker_profile().is_none());
1209 with_broker_profile(profile, || {
1210 assert!(
1211 crate::core::sandbox_seatbelt::execute_sandboxed(
1212 "/usr/bin/true",
1213 &[],
1214 &[],
1215 &[],
1216 1,
1217 None
1218 )
1219 .is_err()
1220 );
1221 });
1222 }
1223
1224 #[test]
1225 fn standard_streams_and_closed_descriptors_are_refused() {
1226 for fd in [-1, 0, 1, 2, i32::MAX] {
1227 assert!(install_client(fd).is_err());
1228 }
1229 }
1230
1231 #[cfg(target_os = "macos")]
1234 fn supervised<T>(denials: &str, exercise: impl FnOnce(&Client) -> T) -> T {
1235 supervised_for_test(denials, exercise)
1236 }
1237
1238 #[cfg(target_os = "macos")]
1239 #[test]
1240 fn a_request_cannot_select_a_weaker_boundary() {
1241 let _lock = crate::core::data_dir::test_env_lock();
1242 crate::core::protected_store::unpin_synthetic_session();
1243 let temp = tempfile::tempdir().expect("temp");
1244 let root = temp.path().canonicalize().unwrap();
1245 let secret = root.join("knowledge.json");
1246 std::fs::write(&secret, "synthetic-provider-fact").unwrap();
1247 let denials = format!("(deny file-read* (subpath \"{}\"))\n", root.display());
1248 supervised(&denials, |client| {
1249 let (_, stderr, code) = client
1250 .program(&[&root], "/usr/bin/true", &[], &[], 10, None)
1251 .expect("ordinary program starts");
1252 assert_eq!(
1253 code, 0,
1254 "the profile must permit normal execution: {stderr}"
1255 );
1256 let (_, _, code) = client
1258 .program(
1259 &[&root],
1260 "/bin/cat",
1261 &[secret.to_str().unwrap()],
1262 &[],
1263 10,
1264 None,
1265 )
1266 .expect("the composed profile must compile and run");
1267 assert_ne!(code, 0, "the launch denials must survive a read root");
1268 });
1269 }
1270
1271 #[test]
1272 fn a_closed_channel_fails_closed_instead_of_running_a_child() {
1273 let (supervisor_end, client_end) = pair();
1274 let reader = client_end.try_clone().expect("clone");
1275 let client = Arc::new(Client {
1276 writer: Mutex::new(client_end),
1277 inflight: Mutex::new(std::collections::HashMap::new()),
1278 next_id: AtomicU64::new(1),
1279 closed: Arc::new(AtomicBool::new(false)),
1280 });
1281 let pump = Arc::clone(&client);
1282 let reader_thread = std::thread::spawn(move || pump.pump(reader));
1283 drop(supervisor_end);
1284 let (text, code) = client.shell(
1285 "echo unreachable",
1286 "/",
1287 &std::collections::HashMap::new(),
1288 Some(5_000),
1289 None,
1290 false,
1291 None,
1292 );
1293 assert_eq!(code, 1);
1294 assert!(text.contains("channel closed"), "{text}");
1295 let _ = reader_thread.join();
1296 }
1297
1298 #[test]
1299 fn a_malformed_frame_ends_the_channel_rather_than_running_it() {
1300 let (supervisor_end, mut client_end) = pair();
1301 let profile = LaunchProfile::synthetic("");
1302 let server = std::thread::spawn(move || serve(supervisor_end, profile));
1303 let payload = b"{\"not\":\"a request\"}";
1304 write_frame(&mut client_end, payload).unwrap();
1305 let frame = read_frame(&mut client_end).unwrap().expect("refusal");
1307 let envelope: Envelope = serde_json::from_slice(&frame).unwrap();
1308 assert!(matches!(envelope.event, Event::Failed { .. }));
1309 assert!(read_frame(&mut client_end).unwrap().is_none());
1310 drop(client_end);
1311 server.join().unwrap().unwrap();
1312 }
1313
1314 #[cfg(target_os = "macos")]
1315 #[test]
1316 fn program_output_larger_than_a_pipe_completes_and_is_bounded() {
1317 let _lock = crate::core::data_dir::test_env_lock();
1318 crate::core::protected_store::unpin_synthetic_session();
1319 let temp = tempfile::tempdir().unwrap();
1320 let root = temp.path().canonicalize().unwrap();
1321 let file = root.join("output.txt");
1322 let content = "q".repeat(512 * 1024);
1323 std::fs::write(&file, &content).unwrap();
1324 supervised("", |client| {
1325 let (stdout, stderr, code) = client
1326 .program(
1327 &[&root],
1328 "/bin/cat",
1329 &[file.to_str().unwrap()],
1330 &[],
1331 10,
1332 None,
1333 )
1334 .unwrap();
1335 assert_eq!(code, 0, "{stderr}");
1336 assert_eq!(stdout, content);
1337 std::fs::write(&file, "q".repeat(2 * 1024 * 1024)).unwrap();
1338 let failure = client
1339 .program(
1340 &[&root],
1341 "/bin/cat",
1342 &[file.to_str().unwrap()],
1343 &[],
1344 10,
1345 None,
1346 )
1347 .unwrap_err();
1348 assert!(failure.contains("capture bound"), "{failure}");
1349 let (stdout, stderr, code) = client
1350 .program(
1351 &[],
1352 "/bin/sh",
1353 &["-c", "printf partial; while :; do :; done"],
1354 &[],
1355 1,
1356 None,
1357 )
1358 .unwrap();
1359 assert_ne!(code, 0);
1360 assert!(stdout.contains("partial"), "{stdout}");
1361 assert!(stderr.contains("timed out"), "{stderr}");
1362 });
1363 }
1364
1365 #[test]
1366 fn oversized_final_output_returns_a_failure_frame() {
1367 let (supervisor_end, mut client_end) = pair();
1368 let supervisor = Supervisor {
1369 writer: Mutex::new(supervisor_end),
1370 profile: LaunchProfile::synthetic(""),
1371 running: Mutex::new(std::collections::HashMap::new()),
1372 };
1373 supervisor.emit(
1374 17,
1375 Event::ShellExit {
1376 text: "x".repeat(MAX_FRAME_BYTES),
1377 code: 0,
1378 },
1379 );
1380 let frame = read_frame(&mut client_end).unwrap().unwrap();
1381 let event: Envelope = serde_json::from_slice(&frame).unwrap();
1382 assert_eq!(event.id, 17);
1383 assert!(matches!(event.event, Event::Failed { .. }));
1384 }
1385}