diff --git a/crates/busbar-kernel/src/plane_host/egress.rs b/crates/busbar-kernel/src/plane_host/egress.rs index 89237b65f2..3a3058611a 100644 --- a/crates/busbar-kernel/src/plane_host/egress.rs +++ b/crates/busbar-kernel/src/plane_host/egress.rs @@ -555,11 +555,12 @@ pub(crate) extern "C-unwind" fn egress_open( // socket rather than a child's stdio. The governance path is identical (resolve-then-pin, // SPKI, mTLS, breaker, meter); joining it is append-only (no ABI change). Refused honestly. EgressKind::RawConn => StatusClass::Unsupported, - // A governed child process, its stdin/stdout the duplex `PipeId` — spawned ONLY under the - // HOST program allowlist. FFI-F3: no operator config wires a subprocess program allowlist - // over the FFI seam today, so the host authorizes NO program (`&[]`) and every plane-driven - // subprocess open is REFUSED at the allowlist. See [`super::pipe`]. - EgressKind::Subprocess => super::pipe::open_subprocess(state, d, &[], out), + // A governed child process. FFI-F3: no operator configuration wires a subprocess program + // allowlist over the FFI seam, so the host authorizes NO program and every plane-driven + // subprocess open is REFUSED, before anything on the descriptor is decoded or resolved. + // The spawning path it once reached ran in no served path and is struck (row 166, F11): + // see [`super::pipe`]. + EgressKind::Subprocess => StatusClass::Refused, } }) } diff --git a/crates/busbar-kernel/src/plane_host/egress_tests.rs b/crates/busbar-kernel/src/plane_host/egress_tests.rs index 6ab78246d8..b395c3fc16 100644 --- a/crates/busbar-kernel/src/plane_host/egress_tests.rs +++ b/crates/busbar-kernel/src/plane_host/egress_tests.rs @@ -287,8 +287,8 @@ fn open_and_poll_fail_closed_on_null_and_bad_kind() { StatusClass::Unsupported ); } - // Subprocess IS wired (the pipe tier), so a non-command target is REFUSED at the host command - // allowlist (undecodable blob / not an absolute program), never spawned. + // Subprocess is REFUSED at the seam: the host authorizes no plane-driven program (FFI-F3), so + // nothing on the descriptor is decoded, resolved or spawned. { let url = b"http://example.test/".to_vec(); let mut d = http_desc(&url); diff --git a/crates/busbar-kernel/src/plane_host/pipe.rs b/crates/busbar-kernel/src/plane_host/pipe.rs index 8f0f5818ff..87ec921cb5 100644 --- a/crates/busbar-kernel/src/plane_host/pipe.rs +++ b/crates/busbar-kernel/src/plane_host/pipe.rs @@ -1,406 +1,33 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! The PIPE tier of the egress family: a governed byte-DUPLEX channel a plane frames on top of. +//! The PIPE tier of the egress family: the two vtable slots a byte-DUPLEX channel is driven +//! through ([`pipe_read`] / [`pipe_write`], keyed by a [`PipeId`]). //! -//! The raw-connection and subprocess egress tiers ([`EgressKind::RawConn`] / [`EgressKind::Subprocess`]) -//! are the SAME shape — a byte duplex keyed by a [`PipeId`], distinguished only by a field on the open -//! POD, not by separate slots (the CLUSTER-3 (c) decision). This module owns the SUBPROCESS wiring: a -//! governed child process whose stdin/stdout are the duplex, opened through the SAME `egress_open` -//! chokepoint as an HTTP hop. +//! NO TIER OPENS A PIPE. The raw-connection tier answers `Unsupported` and the subprocess tier +//! answers `Refused` at the seam ([`super::egress`]): no operator configuration wires a subprocess +//! program allowlist over the FFI seam, so the host authorizes no plane-driven program (FFI-F3). The +//! spawning machinery this module held — the command decode, the host-side environment resolution +//! (a secret reference resolved at the spawn, on the request it served) and the child's lifecycle +//! — ran in no served path and is struck (F11; row 166). A member program a plane needs is spawned +//! by the connector over its declared program need, its environment held since config time +//! (`busbar_core_connector::spawn_env`). //! -//! ## The framing seam: the host is byte-level, the plane frames -//! -//! `pipe_read`/`pipe_write` move RAW BYTES. Line/message framing (the stdio transport's -//! `read_capped_line` logic) stays PLANE-side, layered on top — the host never sees a line. So a -//! JSON-RPC-over-stdio plane writes a framed message with `pipe_write` and reads bytes back with -//! `pipe_read`, doing its own newline framing; the host governs only the CHANNEL. -//! -//! ## The command allowlist is HOST-side -//! -//! The open POD carries the program + argv as neutral DATA only — NO policy. The host enforces the -//! command allowlist here ([`command_admissible`]) BEFORE spawning: the program must be an ABSOLUTE -//! path (the stdio transport's own boot rule, restated at the seam) and the egress scope must permit -//! the subprocess tier. A plane cannot smuggle a relative name or a shell metacharacter through — the -//! program goes straight to `Command::new` with argv as a vector, never a shell string. +//! So both slots answer as they did for every pipe a plane could name: `Gone` (no such pipe), after +//! the host-context and out-pointer checks, and FAIL CLOSED on a caught panic. -use super::{recover, HostState}; +use super::recover; use busbar_contract::abi::hot::host::HostCtx; -use busbar_contract::abi::hot::pod::POD_VERSION; -use busbar_contract::abi::hot::{ - EgressDesc, EgressHead, EgressId, EgressOpen, PipeId, StatusClass, -}; -use busbar_contract::abi::read_sized_field; -use std::collections::HashMap; -use std::io::{Read, Write}; -use std::mem::MaybeUninit; +use busbar_contract::abi::hot::{PipeId, StatusClass}; use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::{Arc, LazyLock, Mutex}; - -/// One open governed subprocess pipe the host owns end to end. The plane holds only its [`PipeId`] and -/// moves bytes through [`pipe_read`]/[`pipe_write`]; the host owns the child's lifecycle. -struct PipeBackend { - /// The child process; taken and killed on close. Behind a `Mutex` because the vtable fns hold only - /// a shared `&PipeBackend` (via the registry) yet must kill/wait. - child: Mutex>, - /// The child's stdin — the WRITE half of the duplex. `None` once closed. - stdin: Mutex>, - /// The child's stdout — the READ half of the duplex. `None` once closed. - stdout: Mutex>, -} - -impl PipeBackend { - /// Read up to `cap` bytes from the child's stdout into `buf`. Blocks for output; `Ok(0)` is a - /// clean end of stream (the child closed its stdout). - /// - /// # Safety - /// `buf`/`cap` must describe a live writable range for the call (ABI discipline). - unsafe fn read(&self, buf: *mut u8, cap: usize) -> (StatusClass, usize) { - if buf.is_null() || cap == 0 { - return (StatusClass::Refused, 0); - } - let mut guard = self.stdout.lock().unwrap_or_else(|e| e.into_inner()); - let Some(stdout) = guard.as_mut() else { - return (StatusClass::Gone, 0); // closed / reclaimed underneath us. - }; - // SAFETY: caller's contract — `buf` is writable for `cap` bytes. - let slice = unsafe { std::slice::from_raw_parts_mut(buf, cap) }; - match stdout.read(slice) { - Ok(n) => (StatusClass::Ok, n), // n == 0 ⇒ EOF (clean end of stream). - Err(_) => (StatusClass::Fault, 0), - } - } - - /// Write `len` bytes to the child's stdin. - /// - /// # Safety - /// `buf`/`len` must describe a live readable range for the call (ABI discipline). - unsafe fn write(&self, buf: *const u8, len: usize) -> StatusClass { - if buf.is_null() && len != 0 { - return StatusClass::Refused; - } - let mut guard = self.stdin.lock().unwrap_or_else(|e| e.into_inner()); - let Some(stdin) = guard.as_mut() else { - return StatusClass::Gone; - }; - // SAFETY: caller's contract — `(buf, len)` is a live readable range. - let slice = if len == 0 { - &[][..] - } else { - unsafe { std::slice::from_raw_parts(buf, len) } - }; - match stdin.write_all(slice).and_then(|()| stdin.flush()) { - Ok(()) => StatusClass::Ok, - Err(_) => StatusClass::Fault, - } - } - - /// Tear the pipe down: drop both stdio halves (signalling EOF to the child), then kill and reap the - /// child so no zombie or wedged process leaks. Idempotent — a second call finds the child taken. - fn close(&self) { - // Drop stdin first so a child reading its input observes EOF and can exit cleanly. - let _ = self.stdin.lock().unwrap_or_else(|e| e.into_inner()).take(); - let _ = self.stdout.lock().unwrap_or_else(|e| e.into_inner()).take(); - if let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() { - let _ = child.kill(); - let _ = child.wait(); - } - } -} - -/// The PROCESS-WIDE registry of open pipes, keyed by a GLOBALLY unique id (the same discipline as the -/// egress [`REGISTRY`](super::egress): two concurrent dispatches each mint arena-local ids from `1`, so -/// the id the plane holds is minted from a process atomic and the backend lives here; the arena still -/// owns RECLAIM via a registered [`Closer`](super::scope::DispatchScope::register_pipe)). -static REGISTRY: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -/// The next globally-unique pipe id. `0` is the reserved [`PipeId::NONE`] sentinel, so ids start at `1`. -static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - -fn registry() -> std::sync::MutexGuard<'static, HashMap>> { - REGISTRY.lock().unwrap_or_else(|e| e.into_inner()) -} - -/// Remove a pipe from the registry and close it. Idempotent: the map remove elects exactly one closer -/// (the arena `Closer`); a second call finds nothing. -fn close_and_remove(id: u64) -> bool { - match registry().remove(&id) { - Some(pipe) => { - pipe.close(); - true - } - None => false, - } -} - -/// Decode the packed `program + argv` blob the [`EgressDesc::target`](EgressDesc) carries for a -/// subprocess open: a sequence of records, each `u32 len` (LE) then `len` bytes. The FIRST record is -/// the program path; the rest are argv. Malformed input yields `None` (fail-closed — an undecodable -/// command is refused, never guessed into a spawn). -/// -/// # Safety -/// `(ptr, len)` MUST describe a live, initialized byte range for the call. -unsafe fn decode_command(ptr: *const u8, len: usize) -> Option<(String, Vec)> { - if ptr.is_null() || len == 0 { - return None; - } - // SAFETY: caller's contract — `(ptr, len)` is a live borrowed range. - let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; - let mut tokens: Vec = Vec::new(); - let mut i = 0usize; - while i < bytes.len() { - let end = i.checked_add(4)?; - let word = bytes.get(i..end)?; - i = end; - let n = u32::from_le_bytes(word.try_into().ok()?) as usize; - let tok_end = i.checked_add(n)?; - let tok = bytes.get(i..tok_end)?; - i = tok_end; - tokens.push(String::from_utf8_lossy(tok).into_owned()); - } - let mut it = tokens.into_iter(); - let program = it.next()?; - Some((program, it.collect())) -} -/// The result of resolving the child's environment: the fully-resolved `(name, value)` pairs the child -/// will get, or a fail-closed refusal (a malformed record, or a secret reference the host could not -/// resolve — the child is NOT spawned with a missing variable). -enum EnvOutcome { - /// The child's whole environment, resolved. Applied under `env_clear()` so it is the ONLY - /// environment the child sees. - Ready(Vec<(String, String)>), - /// A record was malformed or a secret could not be resolved — refuse the spawn. - Refuse, -} - -/// The child-environment VALUE-KIND byte in a packed env record: a literal value, or a host-resolved -/// secret reference (see [`EgressDesc::env_ptr`]). -const ENV_KIND_PLAIN: u8 = 0; -const ENV_KIND_SECRET: u8 = 1; - -/// Decode + RESOLVE the packed subprocess environment the [`EgressDesc::env_ptr`] tail carries. Each -/// record is `u32 name_len` (LE), `name_len` name bytes, a `u8` value-kind, a `u32 value_len` (LE), -/// then `value_len` value bytes. A literal value is taken verbatim; a secret reference is the opaque -/// JSON of a host secret-ref the host turns into plaintext HERE (never off a plane POD), through the -/// SAME built-in resolver the in-process stdio transport uses — so a rotated secret needs no restart -/// and the plaintext never crosses the seam. Fail-closed: a malformed record or an unresolvable secret -/// refuses the whole spawn rather than starting the child with a missing variable. -/// -/// # Safety -/// `(ptr, len)`, when non-null, MUST describe a live, initialized byte range for the call. -unsafe fn resolve_child_env(ptr: *const u8, len: usize) -> EnvOutcome { - if ptr.is_null() || len == 0 { - return EnvOutcome::Ready(Vec::new()); // no records ⇒ an empty (cleared) child environment. - } - // SAFETY: caller's contract — `(ptr, len)` is a live borrowed range. - let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; - let mut out: Vec<(String, String)> = Vec::new(); - let mut i = 0usize; - while i < bytes.len() { - let Some(name) = read_len_prefixed(bytes, &mut i) else { - return EnvOutcome::Refuse; - }; - let Some(&kind) = bytes.get(i) else { - return EnvOutcome::Refuse; - }; - i += 1; - let Some(value_bytes) = read_len_prefixed_bytes(bytes, &mut i) else { - return EnvOutcome::Refuse; - }; - let value = match kind { - ENV_KIND_PLAIN => String::from_utf8_lossy(value_bytes).into_owned(), - ENV_KIND_SECRET => { - // The value is the OPAQUE JSON of a host secret-ref; deserialize and resolve it HERE - // through the built-in resolver — the same `resolve_linked_string` the in-process - // stdio spawn reads a `ChildEnvValue::Secret` with. A failure refuses the spawn. - let Ok(secret_ref) = - serde_json::from_slice::(value_bytes) - else { - return EnvOutcome::Refuse; - }; - match crate::config::secret::resolve_linked_string(&secret_ref) { - Ok(plaintext) => plaintext, - Err(_) => return EnvOutcome::Refuse, // unresolvable secret ⇒ fail-closed. - } - } - _ => return EnvOutcome::Refuse, // an unknown value-kind is never guessed. - }; - out.push((String::from_utf8_lossy(name).into_owned(), value)); - } - EnvOutcome::Ready(out) -} - -/// Read a `u32 len` (LE) then `len` bytes as a borrowed slice, advancing `*i`; `None` on truncation. -fn read_len_prefixed_bytes<'a>(bytes: &'a [u8], i: &mut usize) -> Option<&'a [u8]> { - let end = i.checked_add(4)?; - let word = bytes.get(*i..end)?; - *i = end; - let n = u32::from_le_bytes(word.try_into().ok()?) as usize; - let tok_end = i.checked_add(n)?; - let slice = bytes.get(*i..tok_end)?; - *i = tok_end; - Some(slice) -} - -/// As [`read_len_prefixed_bytes`], but the name arm — kept a distinct helper for the read site's -/// readability (a record reads its name, its kind, then its value). -fn read_len_prefixed<'a>(bytes: &'a [u8], i: &mut usize) -> Option<&'a [u8]> { - read_len_prefixed_bytes(bytes, i) -} - -/// Read the subprocess working directory off the [`EgressDesc`] tail: `Some(dir)` when a non-empty cwd -/// was written, `None` (⇒ inherit the host's cwd) when the field is absent or empty. Read only behind -/// the sized-struct guard so a sender that predates the tail leaves the host's cwd untouched. -fn read_child_cwd(d: &EgressDesc) -> Option { - let ptr = read_sized_field!(d, d.size, EgressDesc, cwd_ptr)?; - let len = read_sized_field!(d, d.size, EgressDesc, cwd_len)?; - if ptr.is_null() || len == 0 { - return None; - } - // SAFETY: a non-null `(cwd_ptr, cwd_len)` is a live borrowed range for the call (ABI discipline). - let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; - Some(String::from_utf8_lossy(bytes).into_owned()) -} - -/// The HOST command allowlist: a program is admissible only when it is an ABSOLUTE path AND it is -/// explicitly named on the HOST-supplied program allowlist. This is policy the HOST owns end to end — -/// the plane's [`EgressDesc`] carries only DATA (never a capability), so the plane's `allowlist_scope` -/// bit is IGNORED here (FFI-F2/F3): a plane cannot self-grant the subprocess tier. -/// -/// The FFI vtable slot passes an EMPTY allowlist (`&[]`) because no operator config wires a subprocess -/// program allowlist over the FFI seam today — so every plane-driven subprocess open is REFUSED -/// (fail-closed by denial; the capability is disabled at the seam, not merely narrowed). A caller that -/// legitimately owns a host-authored allowlist (the in-core stdio-transport posture, exercised in -/// tests) passes it explicitly and only its named absolute programs are admissible. -fn command_admissible(program: &str, program_allowlist: &[String]) -> bool { - std::path::Path::new(program).is_absolute() && program_allowlist.iter().any(|p| p == program) -} - -/// Open a governed SUBPROCESS pipe. Decodes + allowlist-checks the command, spawns the child with piped -/// stdio, registers the backend + the arena closer, and writes an [`EgressOpen`] whose `pipe` is the -/// duplex [`PipeId`]. Called from [`super::egress::egress_open`] for [`EgressKind::Subprocess`]; the -/// caller owns the `catch_unwind` and the null checks. -pub(super) fn open_subprocess( - state: &HostState, - d: &EgressDesc, - program_allowlist: &[String], - out: *mut MaybeUninit, -) -> StatusClass { - // SAFETY: `(target_ptr, target_len)` is a live borrowed range for the call (ABI discipline). - let Some((program, argv)) = (unsafe { decode_command(d.target_ptr, d.target_len) }) else { - return StatusClass::Refused; - }; - if !command_admissible(&program, program_allowlist) { - // Not absolute, or not on the HOST program allowlist. The FFI seam passes an empty allowlist, - // so this REFUSES every plane-driven subprocess spawn (FFI-F3). - return StatusClass::Refused; - } - // THE CHILD'S ENVIRONMENT, resolved host-side and applied under `env_clear()` so the child gets - // ONLY these variables — NEVER the host's own environment, which holds provider API keys, store - // credentials and the operator credential. Inheriting them (as this path once did) would make every governed - // subprocess a silent credential-exfiltration primitive; clearing first is the fix the in-process - // stdio transport already applies, restated at the seam. A malformed record or an unresolvable - // secret refuses the spawn rather than starting the child with a missing variable. - // SAFETY: `(env_ptr, env_len)`, when present, is a live borrowed range for the call (ABI). - let env = match unsafe { - resolve_child_env( - read_sized_field!(d, d.size, EgressDesc, env_ptr).unwrap_or(std::ptr::null()), - read_sized_field!(d, d.size, EgressDesc, env_len).unwrap_or(0), - ) - } { - EnvOutcome::Ready(env) => env, - EnvOutcome::Refuse => return StatusClass::Refused, - }; - // The working directory (empty ⇒ inherit the host's own) and the stderr disposition (default - // discard; inherit sends the child's diagnostics to the host's stderr where an operator reads - // them), both read only behind the sized-struct guard so a sender that predates the tail keeps the - // pre-enrichment shape (an empty environment, the host's cwd, a discarded stderr). - let cwd = read_child_cwd(d); - let stderr = if read_sized_field!(d, d.size, EgressDesc, stderr_inherit) == Some(1) { - Stdio::inherit() - } else { - Stdio::null() - }; - // NO SHELL: the program goes to `Command::new` and argv as a vector — never a shell string, so a - // metacharacter in an arg has no meaning. - let mut builder = Command::new(&program); - builder - .args(&argv) - // THE WHOLE environment, not additions to the host's — `env_clear()` first, then only the - // resolved records. See the note above. - .env_clear() - .envs(env) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(stderr); - if let Some(dir) = &cwd { - builder.current_dir(dir); - } - let child = builder.spawn(); - let mut child = match child { - Ok(child) => child, - Err(_) => return StatusClass::Fault, // spawn failed (e.g. ENOENT) — a host-side fault. - }; - let (Some(stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else { - let _ = child.kill(); - let _ = child.wait(); - return StatusClass::Fault; - }; - let backend = Arc::new(PipeBackend { - child: Mutex::new(Some(child)), - stdin: Mutex::new(Some(stdin)), - stdout: Mutex::new(Some(stdout)), - }); - - let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - registry().insert(id, Arc::clone(&backend)); - // The arena reclaims on dispatch-drop: kill + reap the child, so a cancelled dispatch cannot leak a - // process. (We hand the plane the global id and ignore the arena's own id, like `register_egress`.) - let _ = state.scope.register_pipe(Box::new(move || { - close_and_remove(id); - })); - - let open = EgressOpen { - size: std::mem::size_of::() as u32, - version: POD_VERSION, - _reserved: 0, - // A subprocess is a duplex byte channel, not a one-shot egress: the plane drives the PipeId. - id: EgressId::NONE, - pipe: PipeId(id), - head: EgressHead { - size: std::mem::size_of::() as u32, - version: POD_VERSION, - status_code: 0, // a raw byte channel has no HTTP status. - observed_spki_ptr: std::ptr::null(), - observed_spki_len: 0, - resp_headers_ptr: std::ptr::null(), // a raw byte channel surfaces no response headers. - resp_headers_len: 0, - client_identity_offered: 0, // a raw byte channel offers no client identity. - _reserved4: [0; 7], - }, - }; - // SAFETY: `out` is non-null (checked by the caller) and writable for one `EgressOpen`; written on Ok. - unsafe { - (*out).write(open); - } - StatusClass::Ok -} - -// ───────────────────────────────────────────────────────────────────────────────────────────── -// The two vtable slots. Each recovers the HostState FIRST, runs inside a MANDATORY catch_unwind, and -// FAILS CLOSED (`Fault` / `Gone` / `Refused`) on a caught panic — never a permissive value. -// ───────────────────────────────────────────────────────────────────────────────────────────── - -/// Read raw bytes from a governed pipe into the caller's buffer. Blocks for output; `Ok` with -/// `out_written = 0` is a clean end of stream. +/// Read raw bytes from a governed pipe into the caller's buffer: no pipe is ever open, so every +/// named pipe is `Gone`. pub(crate) extern "C-unwind" fn pipe_read( host: HostCtx, - pipe: PipeId, - buf: *mut u8, - buf_cap: usize, + _pipe: PipeId, + _buf: *mut u8, + _buf_cap: usize, out_written: *mut usize, ) -> StatusClass { catch_unwind(AssertUnwindSafe(|| { @@ -411,39 +38,24 @@ pub(crate) extern "C-unwind" fn pipe_read( if out_written.is_null() { return StatusClass::Refused; } - let Some(backend) = registry().get(&pipe.0).map(Arc::clone) else { - return StatusClass::Gone; // unknown / already closed / reclaimed. - }; - // SAFETY: caller's `buf`/`buf_cap` describe a live writable range (ABI discipline). - let (class, written) = unsafe { backend.read(buf, buf_cap) }; - if class == StatusClass::Ok { - // SAFETY: `out_written` is non-null (checked) and writable for one `usize`. - unsafe { - *out_written = written; - } - } - class + StatusClass::Gone })) .unwrap_or(StatusClass::Fault) } -/// Write raw bytes to a governed pipe (the child's stdin). +/// Write raw bytes to a governed pipe: no pipe is ever open, so every named pipe is `Gone`. pub(crate) extern "C-unwind" fn pipe_write( host: HostCtx, - pipe: PipeId, - buf: *const u8, - len: usize, + _pipe: PipeId, + _buf: *const u8, + _len: usize, ) -> StatusClass { catch_unwind(AssertUnwindSafe(|| { // SAFETY: recovery invariant (see `super::recover`). let Some(_state) = (unsafe { recover(host) }) else { return StatusClass::Refused; }; - let Some(backend) = registry().get(&pipe.0).map(Arc::clone) else { - return StatusClass::Gone; - }; - // SAFETY: caller's `buf`/`len` describe a live readable range (ABI discipline). - unsafe { backend.write(buf, len) } + StatusClass::Gone })) .unwrap_or(StatusClass::Fault) } diff --git a/crates/busbar-kernel/src/plane_host/pipe_tests.rs b/crates/busbar-kernel/src/plane_host/pipe_tests.rs index 868d5b3dea..a76c3928fd 100644 --- a/crates/busbar-kernel/src/plane_host/pipe_tests.rs +++ b/crates/busbar-kernel/src/plane_host/pipe_tests.rs @@ -1,23 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 Busbar Inc and contributors -//! PIPE-tier tests: a governed subprocess byte-duplex opened through the SAME `egress_open` seam, its -//! stdin/stdout driven with `pipe_write`/`pipe_read`, its lifecycle reclaimed by the dispatch arena. -//! `/bin/cat` is the echo-duplex: what the plane writes to stdin comes back on stdout, byte for byte, -//! proving the host moves RAW BYTES (the plane frames on top). -//! -//! FFI-F3: the FFI vtable `egress_open` slot grants NO subprocess capability (it passes an EMPTY host -//! program allowlist, so every plane-driven spawn is refused). The subprocess MECHANICS below are -//! therefore driven through `open_subprocess` with a HOST-AUTHORED program allowlist — the in-core -//! posture a first-party host holds — while `the_ffi_slot_refuses_a_plane_driven_subprocess` proves the -//! untrusted seam refuses the spawn outright. +//! PIPE-tier tests. FFI-F3: the FFI vtable `egress_open` slot grants NO subprocess capability, so +//! every plane-driven spawn is refused — an absolute, runnable program with a secret environment +//! record included, before anything is decoded or resolved — and no pipe is ever open, so both pipe +//! slots answer `Gone` for any pipe a plane names (row 166: the unserved spawning path is struck). -use super::*; -use crate::plane_host::{recover, with_dispatch_scope, HostState}; -use busbar_contract::abi::hot::host::{HostCtx, PlaneHostVtable}; +use crate::plane_host::with_dispatch_scope; use busbar_contract::abi::hot::pod::POD_VERSION; use busbar_contract::abi::hot::{EgressDesc, EgressKind, EgressOpen, PipeId, StatusClass}; -use std::mem::MaybeUninit; /// Pack a `program + argv` command into the length-prefixed wire form (`u32 len | bytes`, LE) that /// [`EgressDesc::target`] carries for a subprocess open. The first token is the program. @@ -31,7 +22,7 @@ fn pack_command(tokens: &[&str]) -> Vec { } /// A subprocess `EgressDesc` borrowing the packed `command` blob. `allowlist_scope` is DATA the host -/// ignores for admission (FFI-F2/F3): the program allowlist, not a plane-asserted scope bit, decides. +/// ignores (FFI-F2/F3): a plane-asserted scope bit grants nothing. fn subprocess_desc(command: &[u8]) -> EgressDesc { EgressDesc { size: std::mem::size_of::() as u32, @@ -68,243 +59,25 @@ fn subprocess_desc(command: &[u8]) -> EgressDesc { } } -/// Open a governed subprocess over the HOST-AUTHORED program allowlist — the in-core posture a -/// first-party host holds (the FFI vtable slot, by contrast, passes an EMPTY allowlist and refuses). -fn host_open_subprocess( - host: HostCtx, - desc: &EgressDesc, - program_allowlist: &[String], - out: *mut MaybeUninit, -) -> StatusClass { - // SAFETY: live HostState minted by with_dispatch_scope. - let state: &HostState = - unsafe { recover(host) }.expect("host generation still live inside with_dispatch_scope"); - open_subprocess(state, desc, program_allowlist, out) -} - -/// A packed child-environment record: `u32 name_len | name | u8 kind | u32 value_len | value`. -fn env_record(name: &str, kind: u8, value: &[u8]) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&(name.len() as u32).to_le_bytes()); - out.extend_from_slice(name.as_bytes()); - out.push(kind); - out.extend_from_slice(&(value.len() as u32).to_le_bytes()); - out.extend_from_slice(value); - out -} - -/// Read a subprocess's whole stdout to EOF (the child is expected to exit after printing). -fn read_to_eof(vt: &PlaneHostVtable, host: HostCtx, pipe: PipeId) -> Vec { - let mut got = Vec::new(); - let mut buf = [0u8; 256]; - for _ in 0..256 { - let mut written: usize = 0; - let class = (vt.pipe_read.unwrap())(host, pipe, buf.as_mut_ptr(), buf.len(), &mut written); - assert_eq!(class, StatusClass::Ok, "pipe_read stays Ok until EOF"); - if written == 0 { - break; // EOF - } - got.extend_from_slice(&buf[..written]); - } - got -} - -/// Read up to `want` bytes from the pipe, accumulating across blocking reads until it has them or the -/// stream ends. Asserts every read stays `Ok`. -fn read_at_least(vt: &PlaneHostVtable, host: HostCtx, pipe: PipeId, want: usize) -> Vec { - let mut got = Vec::new(); - let mut buf = [0u8; 64]; - for _ in 0..64 { - if got.len() >= want { - break; - } - let mut written: usize = 0; - let class = (vt.pipe_read.unwrap())(host, pipe, buf.as_mut_ptr(), buf.len(), &mut written); - assert_eq!(class, StatusClass::Ok, "pipe_read stays Ok until EOF"); - if written == 0 { - break; // EOF - } - got.extend_from_slice(&buf[..written]); - } - got -} - -#[test] -fn subprocess_pipe_echoes_bytes_through_cat() { - // `/bin/cat` exists on macOS and Linux alike; a runner without it is broken, so fail. - assert!( - std::path::Path::new("/bin/cat").exists(), - "/bin/cat is required by this test" - ); - let command = pack_command(&["/bin/cat"]); - let desc = subprocess_desc(&command); - let allowlist = vec!["/bin/cat".to_string()]; - - let app = crate::test_support::TestApp::new().build(); - with_dispatch_scope(&app, |host, vt| { - // SAFETY: live HostState minted by with_dispatch_scope. - let state: &HostState = unsafe { recover(host) } - .expect("host generation still live inside with_dispatch_scope"); - let scope = state.scope; - - let mut out = std::mem::MaybeUninit::::uninit(); - let class = host_open_subprocess(host, &desc, &allowlist, &mut out); - assert_eq!(class, StatusClass::Ok, "the allowlisted subprocess opens"); - // SAFETY: Ok ⇒ initialized. - let open = unsafe { out.assume_init() }; - assert!(!open.pipe.is_none(), "a real PipeId is handed back"); - assert!( - open.id.is_none(), - "a subprocess is a duplex pipe, not a one-shot egress" - ); - assert_eq!( - scope.registered(), - 1, - "open registers exactly one arena closer" - ); - - // WRITE → the child's stdin; READ ← its stdout. cat echoes byte for byte. - let payload = b"ping-through-the-duplex\n"; - assert_eq!( - (vt.pipe_write.unwrap())(host, open.pipe, payload.as_ptr(), payload.len()), - StatusClass::Ok - ); - let echoed = read_at_least(vt, host, open.pipe, payload.len()); - assert_eq!( - &echoed[..payload.len()], - payload, - "the duplex echoed the bytes verbatim" - ); - - // A write to an unknown pipe is Gone. - assert_eq!( - (vt.pipe_write.unwrap())(host, PipeId(999_999), payload.as_ptr(), payload.len()), - StatusClass::Gone - ); - }); -} - -#[test] -fn subprocess_env_is_cleared_and_selective_never_leaking_the_hosts() { - // `/usr/bin/env` prints the child's whole environment, one `NAME=value` per line. Under the seam's - // `env_clear()` + selective `envs`, the child must see ONLY the records the plane named — never the - // host's own environment (which holds provider keys), so the marker is present and the host's - // always-present `PATH` is not. This is the CONFIRMED regression this carrier closes. - assert!( - std::path::Path::new("/usr/bin/env").exists(), - "/usr/bin/env is required by this test" - ); - let command = pack_command(&["/usr/bin/env"]); - let env = env_record("BUSBAR_ENV_MARKER", 0, b"present"); - let mut desc = subprocess_desc(&command); - desc.env_ptr = env.as_ptr(); - desc.env_len = env.len(); - let allowlist = vec!["/usr/bin/env".to_string()]; - - let app = crate::test_support::TestApp::new().build(); - with_dispatch_scope(&app, |host, vt| { - let mut out = std::mem::MaybeUninit::::uninit(); - assert_eq!( - host_open_subprocess(host, &desc, &allowlist, &mut out), - StatusClass::Ok - ); - // SAFETY: Ok ⇒ initialized. - let open = unsafe { out.assume_init() }; - let printed = String::from_utf8_lossy(&read_to_eof(vt, host, open.pipe)).into_owned(); - assert!( - printed.contains("BUSBAR_ENV_MARKER=present"), - "the child sees the variable the plane named; got: {printed:?}" - ); - assert!( - !printed.contains("PATH="), - "env_clear() ran first, so the host's own environment (its PATH, its secrets) never \ - reaches the child; got: {printed:?}" - ); - }); -} - -#[test] -fn subprocess_env_resolves_a_secret_reference_host_side() { - // A `Secret` env record carries the OPAQUE JSON of a host secret-ref; the host resolves it to - // plaintext at spawn through the built-in resolver, exactly as the in-process stdio spawn does. - // The `env` module reads a host environment variable — `HOME` is present in any test environment — - // so the child ends up with the resolved value the plane never held. - assert!( - std::path::Path::new("/usr/bin/env").exists(), - "/usr/bin/env is required by this test" - ); - let home = std::env::var("HOME").expect("HOME must be set for this test"); - assert!(!home.is_empty(), "HOME must be non-empty for this test"); - let command = pack_command(&["/usr/bin/env"]); - // The sugar form the config layer accepts: `{ env: HOME }` ⇒ the `env` secret module, key `HOME`. - let secret_json = br#"{"env":"HOME"}"#; - let env = env_record("BUSBAR_INJECTED", 1, secret_json); - let mut desc = subprocess_desc(&command); - desc.env_ptr = env.as_ptr(); - desc.env_len = env.len(); - let allowlist = vec!["/usr/bin/env".to_string()]; - - let app = crate::test_support::TestApp::new().build(); - with_dispatch_scope(&app, |host, vt| { - let mut out = std::mem::MaybeUninit::::uninit(); - assert_eq!( - host_open_subprocess(host, &desc, &allowlist, &mut out), - StatusClass::Ok - ); - // SAFETY: Ok ⇒ initialized. - let open = unsafe { out.assume_init() }; - let printed = String::from_utf8_lossy(&read_to_eof(vt, host, open.pipe)).into_owned(); - assert!( - printed.contains(&format!("BUSBAR_INJECTED={home}")), - "the host resolved the secret reference to plaintext and handed it to the child; got: \ - {printed:?}" - ); - }); -} - -#[test] -fn open_refuses_a_relative_or_unlisted_command() { - let app = crate::test_support::TestApp::new().build(); - with_dispatch_scope(&app, |host, _vt| { - let mut out = std::mem::MaybeUninit::::uninit(); - let allowlist = vec!["/bin/cat".to_string()]; - - // Absolute path but NOT on the host program allowlist → Refused (the allowlist gates the tier). - let cmd = pack_command(&["/bin/ls"]); - let unlisted = subprocess_desc(&cmd); - assert_eq!( - host_open_subprocess(host, &unlisted, &allowlist, &mut out), - StatusClass::Refused, - "a program the host did not allowlist is refused" - ); - - // On the allowlist, but a RELATIVE program name is refused (the host allowlist demands absolute). - let relative = pack_command(&["cat"]); - let rel = subprocess_desc(&relative); - assert_eq!( - host_open_subprocess(host, &rel, &["cat".to_string()], &mut out), - StatusClass::Refused, - "a relative program name is never admissible" - ); - - // An empty/undecodable command blob is refused, not spawned. - let empty = subprocess_desc(&[]); - assert_eq!( - host_open_subprocess(host, &empty, &allowlist, &mut out), - StatusClass::Refused - ); - }); -} - /// FFI-F3 (subprocess egress no allowlist): the FFI vtable `egress_open` slot passes an EMPTY host /// program allowlist, so a plane-driven subprocess spawn is REFUSED outright — even an absolute, /// otherwise-runnable program. The capability is disabled at the untrusted seam, not merely narrowed. #[test] fn the_ffi_slot_refuses_a_plane_driven_subprocess() { let command = pack_command(&["/bin/cat"]); + // A child-environment record naming a SECRET reference (kind 1): refused before it is read. + let secret = br#"{"module":"env","settings":{"name":"BUSBAR_PIPE_NEVER_RESOLVED"}}"#; + let mut env = Vec::new(); + env.extend_from_slice(&5u32.to_le_bytes()); + env.extend_from_slice(b"TOKEN"); + env.push(1); + env.extend_from_slice(&(secret.len() as u32).to_le_bytes()); + env.extend_from_slice(secret); // The plane even asserts a (meaningless) privilege bit — it carries no authority over the seam. let mut desc = subprocess_desc(&command); desc.allowlist_scope = u32::MAX; + desc.env_ptr = env.as_ptr(); + desc.env_len = env.len(); let app = crate::test_support::TestApp::new().build(); with_dispatch_scope(&app, |host, vt| { @@ -314,32 +87,29 @@ fn the_ffi_slot_refuses_a_plane_driven_subprocess() { StatusClass::Refused, "the FFI seam admits no plane-driven subprocess — no host program allowlist grants it" ); - }); -} - -#[test] -fn arena_drop_reclaims_and_kills_an_unclosed_subprocess() { - assert!( - std::path::Path::new("/bin/cat").exists(), - "/bin/cat is required by this test" - ); - let command = pack_command(&["/bin/cat"]); - let desc = subprocess_desc(&command); - let allowlist = vec!["/bin/cat".to_string()]; - - let app = crate::test_support::TestApp::new().build(); - let leaked = with_dispatch_scope(&app, |host, _vt| { - let mut out = std::mem::MaybeUninit::::uninit(); + // No pipe is ever open: both slots answer `Gone` for any pipe a plane names. + let mut buf = [0u8; 8]; + let mut written = 0usize; + for pipe in [PipeId(0), PipeId(1), PipeId(u64::MAX)] { + assert_eq!( + (vt.pipe_read.unwrap())(host, pipe, buf.as_mut_ptr(), buf.len(), &mut written), + StatusClass::Gone + ); + assert_eq!( + (vt.pipe_write.unwrap())(host, pipe, buf.as_ptr(), buf.len()), + StatusClass::Gone + ); + } assert_eq!( - host_open_subprocess(host, &desc, &allowlist, &mut out), - StatusClass::Ok + (vt.pipe_read.unwrap())( + host, + PipeId(1), + buf.as_mut_ptr(), + buf.len(), + std::ptr::null_mut() + ), + StatusClass::Refused, + "a null out-pointer is refused before the pipe is looked at" ); - // SAFETY: Ok ⇒ initialized. Deliberately do NOT close — the dispatch ends with it open. - unsafe { out.assume_init() }.pipe }); - // The dispatch scope dropped: its arena Closer killed the child and removed the backend. - assert!( - !REGISTRY.lock().unwrap().contains_key(&leaked.0), - "arena drop reclaims (kills + reaps) a subprocess the plane never closed" - ); } diff --git a/qa/kind-isolation.toml b/qa/kind-isolation.toml index 1e97362c49..2f66e797a0 100644 --- a/qa/kind-isolation.toml +++ b/qa/kind-isolation.toml @@ -1806,8 +1806,8 @@ cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain [[law0]] crate = "busbar-kernel" axis = "transport" -count = "266" -cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 288 -> 286, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 286 -> 285 by GetBusbar/busbar#570: the kernel's auth proof no longer cites the root's stdio-served proof; LOWERED 285 -> 284 by row 157: the hit drained with the same deletion; REBALANCED 284 -> 267 (-17), the verbatim listener serve loop moved out of busbar-kernel into the root (#710), paired with busbar x transport 208 -> 225 (+17) so the law0 total does not rise (ARCHITECT RULING 2026-10-10, https://github.com/GetBusbar/busbar/pull/710#issuecomment-6096420101; BUSBAR-1.6.0.md l.475-483 INBOUND-LISTEN: the root's stream hand-up); LOWERED 267 -> 266 by GetBusbar/busbar#532 (Q1): the kernel's cold auth path is deleted (auth/token.rs's `\"http\"` scheme check), a different hit from row 157's" +count = "256" +cite = "measured on predev f830faa691 (ARCHITECT 2026-10-07, Law 9); the drain is THE DESIGN §1 / DoD item 2: core names no instance; LOWERED 288 -> 286, predev 9c0a394771's measure: the drain landed without giving its row back (Law 9: the ceiling ratchets down); LOWERED 286 -> 285 by GetBusbar/busbar#570: the kernel's auth proof no longer cites the root's stdio-served proof; LOWERED 285 -> 284 by row 157: the hit drained with the same deletion; REBALANCED 284 -> 267 (-17), the verbatim listener serve loop moved out of busbar-kernel into the root (#710), paired with busbar x transport 208 -> 225 (+17) so the law0 total does not rise (ARCHITECT RULING 2026-10-10, https://github.com/GetBusbar/busbar/pull/710#issuecomment-6096420101; BUSBAR-1.6.0.md l.475-483 INBOUND-LISTEN: the root's stream hand-up); LOWERED 267 -> 266 by GetBusbar/busbar#532 (Q1): the kernel's cold auth path is deleted (auth/token.rs's `\"http\"` scheme check), a different hit from row 157's; LOWERED 266 -> 256 by GetBusbar/busbar#866 (row 166): the plane FFI subprocess spawn path (plane_host/egress.rs, plane_host/pipe.rs and their suites) ran in no served path and is struck, taking ten transport hits with it (the gate's measure on #866, law0-base: 10 drained; Law 9: the ceiling only falls)" [[law0]] crate = "busbar-kernel-breaker"