//! [`PortablePtyAdapter`] — local [`PtyPort`] implementation over the //! `portable-pty` crate (ARCHITECTURE §5, L3). //! //! # Design //! //! `portable-pty` is a blocking, thread-oriented API: a master PTY gives a //! `Box` reader and a `Box` writer, and the child process is //! waited on a thread. We bridge that to the domain [`PtyPort`] as follows: //! //! - [`PtyHandle`] only carries a [`SessionId`]; the *real* OS handles (master //! PTY, writer, child) live in this adapter's registry keyed by that id. The //! domain never sees an OS handle (ARCHITECTURE §4). //! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the //! slave, then start **one reader thread** that pumps bytes from the master //! into a shared [`Broadcast`] hub. The hub does two things with every chunk: //! it appends to a bounded **scrollback ring buffer** (~100 KB, most recent //! bytes) and it fans the chunk out to every *currently subscribed* receiver. //! - [`subscribe_output`](PtyPort::subscribe_output) registers a fresh //! subscriber and returns its receiver wrapped as the domain's blocking //! [`OutputStream`] iterator. It is **re-subscribable**: after a view tears //! down (navigation / layout change) a new view can re-attach to the *same* //! live PTY by subscribing again and repainting the scrollback first — no //! re-spawn. [`scrollback`](PtyPort::scrollback) returns that retained buffer. //! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored //! writer / master. [`kill`](PtyPort::kill) terminates the child, joins the //! reader thread, and returns the [`ExitStatus`]. //! //! # Cross-platform note (spike, ARCHITECTURE §13.1) //! //! `portable-pty` abstracts ConPTY on Windows, but exit-code/signal semantics //! differ: a Unix process killed by a signal reports `code: None` here, while //! ConPTY surfaces a numeric code. Resize uses the same `PtySize` everywhere. //! Points to validate on Windows: child `kill()` actually tears down ConPTY, and //! the reader thread observes EOF promptly on exit. The code below avoids any //! Unix-only assumption (no raw fds, no signals) so it should port as-is. use std::collections::HashMap; use std::collections::VecDeque; use std::io::{Read, Write}; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::Duration; use async_trait::async_trait; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty}; use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec}; use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::terminal::PtySize; use domain::SessionId; /// Size of each read buffer pumped from the master PTY. const READ_BUF: usize = 8 * 1024; /// Maximum number of bytes retained in a session's scrollback ring buffer /// (~100 KB, "the most recent output"). When the buffer would exceed this, the /// oldest bytes are dropped so a re-attaching view repaints recent history. const SCROLLBACK_CAP: usize = 100 * 1024; type SharedChild = Arc>>>; type SharedExitStatus = Arc>>; /// The shared output hub of one PTY: a bounded scrollback ring buffer plus the /// **single** currently-subscribed receiver. The reader thread feeds both; a /// view subscribes on (re-)attach, and each new subscription supersedes the /// previous one ([`Broadcast::subscribe`]) so a PTY is never fanned to two live /// consumers at once. /// /// A subscriber is a [`Sender`]; a send failing (receiver dropped because the /// view detached) prunes it on the next chunk. The `Vec` is retained (rather /// than an `Option`) only to keep the prune-on-send logic uniform. #[derive(Default)] struct Broadcast { /// Bounded ring buffer of the most recent output bytes. scrollback: VecDeque, /// Live subscribers; pruned lazily when their receiver is gone. subscribers: Vec>>, /// Set once the PTY hit EOF (process exited) — no more output will ever come. eof: bool, } impl Broadcast { /// Appends a chunk to the scrollback (trimming to [`SCROLLBACK_CAP`]) and /// fans it out to every live subscriber, dropping any that have gone away. fn push(&mut self, chunk: &[u8]) { self.scrollback.extend(chunk.iter().copied()); let overflow = self.scrollback.len().saturating_sub(SCROLLBACK_CAP); if overflow > 0 { self.scrollback.drain(0..overflow); } self.subscribers .retain(|tx| tx.send(chunk.to_vec()).is_ok()); } /// Registers a new subscriber, returning its receiver. /// /// **Single live consumer.** A PTY session is shown by exactly one view at a /// time; a re-attach supersedes the previous view. So a new subscription /// **drops every prior subscriber**, which ends the previous attach's pump /// thread (its receiver closes) instead of leaving it alive to fan the *same* /// bytes a second time — the root cause of on-resize output duplication. /// /// If the PTY already hit EOF, the returned stream is immediately closed /// (its sender is dropped) so a late re-attach to a finished session doesn't /// block forever waiting for output that will never come. fn subscribe(&mut self) -> Receiver> { let (tx, rx) = mpsc::channel(); self.subscribers.clear(); if !self.eof { self.subscribers.push(tx); } rx } /// Returns the retained scrollback as a contiguous byte vector. fn snapshot(&self) -> Vec { self.scrollback.iter().copied().collect() } /// Drops every subscriber's sender so their output streams end (EOF). Called /// by the reader thread when the PTY hits EOF (process exit). The scrollback /// is preserved so a late re-attach can still repaint the final output. fn close_subscribers(&mut self) { self.eof = true; self.subscribers.clear(); } } /// A live PTY owned by the adapter. struct LivePty { /// Master side — used for resize. master: Box, /// Writer into the PTY (child stdin). writer: Box, /// The spawned child process, retained until wait/try_wait/kill observes its /// exit status. child: SharedChild, /// Memorized exit status. Kept while the PTY session is registered so /// wait/try_wait/kill are idempotent relative to each other. exit_status: SharedExitStatus, /// Shared scrollback + subscriber hub, fed by the reader thread. output: Arc>, /// Handle of the reader thread, joined on kill. reader: Option>, } /// Local PTY adapter backed by `portable-pty`'s native PTY system. /// /// Thread-safe: the registry of live PTYs is behind a [`Mutex`]; the adapter is /// cloneable-as-`Arc` and injected as `Arc` at the composition root. #[derive(Default)] pub struct PortablePtyAdapter { sessions: Mutex>, /// Optional OS-sandbox enforcer (lot LP4-1). `None` ⇒ no sandboxing at all /// (the default, zero behaviour change). When set **and** a [`SpawnSpec`] /// carries a [`SandboxPlan`], the plan is enforced on the child at spawn /// (see [`spawn_command_sandboxed`]). sandbox_enforcer: Option>, } impl PortablePtyAdapter { /// Creates an empty adapter (no OS sandbox). #[must_use] pub fn new() -> Self { Self { sessions: Mutex::new(HashMap::new()), sandbox_enforcer: None, } } /// Additive builder: wires an OS-sandbox [`SandboxEnforcer`] (lot LP4-1). With /// it set, any [`SpawnSpec`] whose `sandbox` is `Some` has that plan enforced on /// the spawned child. Without it (the default), no spawn is ever sandboxed — /// `new()`'s behaviour is unchanged. #[must_use] pub fn with_sandbox_enforcer(mut self, enforcer: Arc) -> Self { self.sandbox_enforcer = Some(enforcer); self } } /// Maps the domain [`PtySize`] to the `portable-pty` one. fn to_pty_size(size: PtySize) -> portable_pty::PtySize { portable_pty::PtySize { rows: size.rows, cols: size.cols, pixel_width: 0, pixel_height: 0, } } /// Spawns the child **under an OS sandbox** (lot LP4-1). /// /// ## Why a dedicated thread instead of a `pre_exec` hook /// /// `portable-pty` 0.9 exposes **no** user-injectable `pre_exec` closure: its /// `CommandBuilder` has no such method, and `SlavePty::spawn_command` installs its /// *own* internal `pre_exec` (for `setsid` / controlling-tty) before exec, with no /// extension point. So we cannot run `enforcer.enforce(plan)` in the child's /// `pre_exec` directly. /// /// Instead we lean on a guaranteed kernel property: **a Landlock domain is /// inherited across `fork` and preserved across `execve`**. We therefore restrict a /// **dedicated throwaway thread** with `enforce`, then call `spawn_command` *from /// that thread*. `std::process::Command` (which `spawn_command` drives) forks from /// the calling thread — and because `portable-pty` always sets a `pre_exec`, std is /// forced down the `fork`+`exec` path (never `posix_spawn`) — so the child inherits /// the restricted thread's domain. The throwaway thread then dies, taking its /// (irreversible) restriction with it, leaving IdeA's other threads untouched. /// /// On `enforce` returning `Err` (fail-closed, e.g. a `Deny` posture on a kernel /// without Landlock) the spawn is failed and **no child runs**. `Ok(Unsupported / /// Degraded)` proceeds (best-effort). /// /// Everything is **moved** into the thread (no borrow), so no `Sync` bound is /// required of the slave; the resulting `Child` is `Send` and returned to the /// caller. fn spawn_command_sandboxed( slave: Box, cmd: CommandBuilder, enforcer: Arc, plan: SandboxPlan, ) -> Result, PtyError> { let join = std::thread::spawn(move || -> Result, PtyError> { // Restrict THIS thread; the forked child inherits the domain. if let Err(e) = enforcer.enforce(&plan) { return Err(PtyError::Spawn(format!("sandbox enforcement failed: {e}"))); } let child = slave .spawn_command(cmd) .map_err(|e| PtyError::Spawn(e.to_string()))?; // The slave is held by the child; drop our copy so EOF propagates on exit. drop(slave); Ok(child) }); join.join() .map_err(|_| PtyError::Spawn("sandbox spawn thread panicked".to_owned()))? } /// Builds the `portable-pty` command from a domain [`SpawnSpec`]. fn to_command(spec: &SpawnSpec) -> CommandBuilder { let mut cmd = CommandBuilder::new(&spec.command); cmd.args(&spec.args); cmd.cwd(spec.cwd.as_str()); for (k, v) in &spec.env { cmd.env(k, v); } cmd } fn portable_status(status: &portable_pty::ExitStatus) -> ExitStatus { ExitStatus { code: exit_code(status), } } fn try_wait_shared( child: &SharedChild, exit_status: &SharedExitStatus, ) -> Result, PtyError> { if let Some(status) = *exit_status .lock() .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? { return Ok(Some(status)); } let observed = { let mut child_guard = child .lock() .map_err(|_| PtyError::Io("pty child lock poisoned".to_owned()))?; let Some(child) = child_guard.as_mut() else { return Ok(*exit_status .lock() .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?); }; match child.try_wait().map_err(|e| PtyError::Io(e.to_string()))? { Some(status) => { let status = portable_status(&status); *child_guard = None; Some(status) } None => None, } }; if let Some(status) = observed { *exit_status .lock() .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? = Some(status); Ok(Some(status)) } else { Ok(None) } } fn kill_shared( child: &SharedChild, exit_status: &SharedExitStatus, ) -> Result { if let Some(status) = *exit_status .lock() .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? { return Ok(status); } let status = { let mut child_guard = child .lock() .map_err(|_| PtyError::Io("pty child lock poisoned".to_owned()))?; let Some(child) = child_guard.as_mut() else { return exit_status .lock() .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? .ok_or_else(|| PtyError::Io("pty child missing exit status".to_owned())); }; let _ = child.kill(); let raw = child.wait().map_err(|e| PtyError::Io(e.to_string()))?; let status = portable_status(&raw); *child_guard = None; status }; *exit_status .lock() .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? = Some(status); Ok(status) } #[async_trait] impl PtyPort for PortablePtyAdapter { async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result { let pty_system = NativePtySystem::default(); let pair = pty_system .openpty(to_pty_size(size)) .map_err(|e| PtyError::Spawn(e.to_string()))?; let cmd = to_command(&spec); // Move the slave out of the pair (the master stays usable) so it can be // either spawned directly or handed to the sandboxed-spawn thread. let slave = pair.slave; let child = match (spec.sandbox.clone(), self.sandbox_enforcer.clone()) { // Sandboxed spawn: a plan AND an enforcer are present. (Some(plan), Some(enforcer)) => spawn_command_sandboxed(slave, cmd, enforcer, plan)?, // No plan or no enforcer ⇒ plain spawn (the default path, unchanged). _ => { let child = slave .spawn_command(cmd) .map_err(|e| PtyError::Spawn(e.to_string()))?; // The slave is held by the child; drop our copy so EOF propagates. drop(slave); child } }; let writer = pair .master .take_writer() .map_err(|e| PtyError::Io(e.to_string()))?; let mut reader = pair .master .try_clone_reader() .map_err(|e| PtyError::Io(e.to_string()))?; // One reader thread per PTY pumps bytes into the broadcast hub until EOF. // The hub retains a scrollback ring buffer AND fans bytes out to every // current subscriber, so views can detach/re-attach without re-spawning. let output: Arc> = Arc::new(Mutex::new(Broadcast::default())); let output_for_reader = Arc::clone(&output); let reader_handle = std::thread::spawn(move || { let mut buf = [0u8; READ_BUF]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { if let Ok(mut hub) = output_for_reader.lock() { hub.push(&buf[..n]); } } Err(_) => break, } } // EOF (process exited): end every attached stream by dropping its // sender, while preserving the scrollback for any late re-attach. if let Ok(mut hub) = output_for_reader.lock() { hub.close_subscribers(); } }); // The PTY layer owns the handle identity: it mints a fresh session id and // the caller (the `OpenTerminal` use case) adopts it as the // `TerminalSession.id`. This keeps the OS handle out of the domain while // giving everyone a single, agreed-upon id (ARCHITECTURE §4). let handle = PtyHandle { session_id: SessionId::new_random(), }; let live = LivePty { master: pair.master, writer, child: Arc::new(Mutex::new(Some(child))), exit_status: Arc::new(Mutex::new(None)), output, reader: Some(reader_handle), }; self.sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))? .insert(handle.session_id, live); Ok(handle) } fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { let mut map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?; live.writer .write_all(data) .map_err(|e| PtyError::Io(e.to_string()))?; live.writer.flush().map_err(|e| PtyError::Io(e.to_string())) } fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> { let map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?; live.master .resize(to_pty_size(size)) .map_err(|e| PtyError::Io(e.to_string())) } fn subscribe_output(&self, handle: &PtyHandle) -> Result { let map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?; let rx = live .output .lock() .map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))? .subscribe(); Ok(Box::new(rx.into_iter())) } fn scrollback(&self, handle: &PtyHandle) -> Result, PtyError> { let map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?; let snapshot = live .output .lock() .map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))? .snapshot(); Ok(snapshot) } async fn wait(&self, handle: &PtyHandle) -> Result { loop { if let Some(status) = self.try_wait(handle)? { return Ok(status); } tokio::time::sleep(Duration::from_millis(20)).await; } } fn try_wait(&self, handle: &PtyHandle) -> Result, PtyError> { let (child, exit_status) = { let map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?; (Arc::clone(&live.child), Arc::clone(&live.exit_status)) }; try_wait_shared(&child, &exit_status) } async fn kill(&self, handle: &PtyHandle) -> Result { // Remove from the registry so the writer/master drop and the child is // fully owned here while we tear it down. let mut live = { let mut map = self .sessions .lock() .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?; map.remove(&handle.session_id).ok_or(PtyError::NotFound)? }; // Ask the child to terminate, unless wait/try_wait already memorized its // natural exit status. let status = kill_shared(&live.child, &live.exit_status)?; // Dropping master/writer closes the PTY; the reader thread then sees EOF. // Dropping the broadcast hub drops every subscriber's sender, so any // still-attached view's output stream ends cleanly too. if let Some(reader) = live.reader.take() { let _ = reader.join(); } Ok(status) } } /// Extracts a portable exit code. `portable-pty`'s `ExitStatus` exposes /// `exit_code(): u32`; `0` is success. We surface it as `i32`. fn exit_code(status: &portable_pty::ExitStatus) -> Option { Some(status.exit_code() as i32) } #[cfg(test)] mod tests { use super::Broadcast; /// Regression: a re-attach (new subscribe) must end the previous subscriber's /// stream so only ONE consumer is fed. Two live subscribers would each pump /// the same bytes to the bridge → the on-resize output duplication. #[test] fn subscribe_supersedes_the_previous_subscriber() { let mut hub = Broadcast::default(); let first = hub.subscribe(); let second = hub.subscribe(); hub.push(b"hello"); // The superseded receiver's stream is closed (sender dropped) and gets // nothing; only the current subscriber receives the chunk. assert!(first.recv().is_err(), "old subscriber must be disconnected"); assert_eq!(second.recv().unwrap(), b"hello".to_vec()); } #[test] fn subscribe_after_eof_yields_a_closed_stream() { let mut hub = Broadcast::default(); hub.close_subscribers(); let rx = hub.subscribe(); assert!(rx.recv().is_err(), "no output will ever come after EOF"); } #[test] fn snapshot_retains_scrollback_across_resubscribe() { let mut hub = Broadcast::default(); let _first = hub.subscribe(); hub.push(b"abc"); // A re-attach drops the old subscriber but the scrollback is preserved. let _second = hub.subscribe(); assert_eq!(hub.snapshot(), b"abc".to_vec()); } } /// **End-to-end PTY × Landlock enforcement (lot LP4-3).** /// /// These tests prove the OS sandbox is *really live on the raw PTY path*: a real /// [`PortablePtyAdapter`] wired with the Landlock enforcer, a [`SpawnSpec`] whose /// `sandbox = Some(plan)`, and a harmless `sh -c` that tries to write **inside** the /// granted root (must succeed) and **outside** it (must be blocked by the kernel). /// No AI CLI is launched — zero tokens. The whole chain enforcer → spec.sandbox → /// `spawn` → `spawn_command_sandboxed` → restricted thread → fork/exec is exercised. #[cfg(all(test, target_os = "linux"))] mod sandbox_e2e_tests { use super::*; use crate::sandbox::LandlockSandbox; use domain::sandbox::{PathAccess, PathGrant, SandboxPlan, SandboxStatus}; use domain::terminal::PtySize; use domain::Posture; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; /// A unique temp dir for one test (no external tempfile dep), mirroring the /// helper used by the Landlock adapter tests. fn fresh_dir(tag: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let p = std::env::temp_dir().join(format!("idea-pty-sandbox-{tag}-{nanos}")); std::fs::create_dir_all(&p).unwrap(); p } /// Probes whether the running kernel actually enforces Landlock, exactly the way /// the launch path would observe it: enforce a tiny RW plan on a throwaway thread /// (the restriction is irreversible, so it must not run on the test thread) and /// report `false` when the adapter returns [`SandboxStatus::Unsupported`]. Lets us /// skip cleanly on CI / old kernels without a Landlock LSM, like the adapter tests. fn landlock_is_enforced() -> bool { let probe = fresh_dir("probe"); let plan = SandboxPlan { allowed: vec![PathGrant { abs_root: probe.to_string_lossy().into_owned(), access: PathAccess::RW, }], default_posture: Posture::Ask, }; let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan)) .join() .unwrap() .expect("enforce must not error under an Ask posture"); let _ = std::fs::remove_dir_all(&probe); !matches!(status, SandboxStatus::Unsupported) } /// Waits (bounded) for `path` to appear, returning whether it showed up in time. fn wait_for(path: &Path, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { if path.exists() { return true; } std::thread::sleep(Duration::from_millis(25)); } path.exists() } /// THE high-fidelity end-to-end proof. With the enforcer wired and a plan that /// grants RW on `allowed` only, a child `sh` writing to **both** an out-of-grant /// path and the granted path must have its out-of-grant write blocked while the /// in-grant write lands on disk. The script writes the denied target *first*, so /// once the allowed marker exists we know the denied write was already attempted /// (and fenced) — making the assertion deterministic without racing the child. #[tokio::test] async fn pty_spawn_enforces_sandbox_plan_end_to_end() { if !landlock_is_enforced() { eprintln!( "skipping pty_spawn_enforces_sandbox_plan_end_to_end: \ Landlock not available/enforced on this kernel" ); return; } let allowed = fresh_dir("allowed"); let denied = fresh_dir("denied"); let allowed_marker = allowed.join("in.txt"); let denied_marker = denied.join("out.txt"); // RW on `allowed` only ⇒ the write class is handled and fenced to that root; // reads stay globally unrestricted so `sh`/libc still load normally. let plan = SandboxPlan { allowed: vec![PathGrant { abs_root: allowed.to_string_lossy().into_owned(), access: PathAccess::RW, }], default_posture: Posture::Ask, }; // Denied write FIRST, then the allowed write: the allowed marker appearing is // a happens-after signal that the denied attempt already ran. let script = format!( "echo outside > '{}'; echo inside > '{}'", denied_marker.display(), allowed_marker.display() ); let spec = SpawnSpec { command: "sh".to_owned(), args: vec!["-c".to_owned(), script], cwd: domain::project::ProjectPath::new(allowed.to_string_lossy().into_owned()) .expect("temp dir is absolute"), env: vec![], context_plan: None, sandbox: Some(plan), }; let adapter = PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()); let handle = adapter .spawn(spec, PtySize { rows: 24, cols: 80 }) .await .expect("sandboxed spawn must succeed (Ask posture never fails closed)"); // Wait for the in-grant write to land (the child's last action). assert!( wait_for(&allowed_marker, Duration::from_secs(5)), "in-grant write must succeed: {allowed_marker:?} never appeared — \ the sandbox may have wrongly fenced the granted root" ); // THE GUARANTEE: the out-of-grant write was attempted before the marker and // must have been blocked by Landlock, so the file must not exist. assert!( !denied_marker.exists(), "SANDBOX BREACH: out-of-grant write succeeded ({denied_marker:?} exists) — \ the Landlock plan was NOT enforced on the PTY child" ); let _ = adapter.kill(&handle).await; let _ = std::fs::remove_dir_all(&allowed); let _ = std::fs::remove_dir_all(&denied); } /// Companion sanity check: with the **same** adapter+enforcer but a [`SpawnSpec`] /// carrying `sandbox = None`, the out-of-grant write must succeed — proving the /// previous test's block comes from the *enforced plan*, not from some ambient PTY /// restriction. (Guards against a false-positive where writes fail for unrelated /// reasons.) `denied` here is just an ordinary writable temp dir. #[tokio::test] async fn pty_spawn_without_plan_does_not_sandbox() { let denied = fresh_dir("noplan"); let marker = denied.join("out.txt"); let script = format!("echo outside > '{}'", marker.display()); let spec = SpawnSpec { command: "sh".to_owned(), args: vec!["-c".to_owned(), script], cwd: domain::project::ProjectPath::new(denied.to_string_lossy().into_owned()) .expect("temp dir is absolute"), env: vec![], context_plan: None, sandbox: None, }; let adapter = PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()); let handle = adapter .spawn(spec, PtySize { rows: 24, cols: 80 }) .await .expect("plain spawn must succeed"); assert!( wait_for(&marker, Duration::from_secs(5)), "without a sandbox plan the write must succeed: {marker:?} never appeared" ); let _ = adapter.kill(&handle).await; let _ = std::fs::remove_dir_all(&denied); } }