From 25881f10373255c21a4a412e512a8624fc35bc75 Mon Sep 17 00:00:00 2001 From: Drew Newberry <385+drew@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:45:36 +0000 Subject: [PATCH] feat(isolation): add RFC 0012 backend contract Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com> --- AGENTS.md | 1 + Cargo.lock | 9 + crates/openshell-isolation/BUILD.bazel | 30 + crates/openshell-isolation/Cargo.toml | 22 + crates/openshell-isolation/src/contract.rs | 609 +++++++++++++++ .../openshell-isolation/src/contract/tests.rs | 716 ++++++++++++++++++ crates/openshell-isolation/src/lib.rs | 50 ++ rfc/0012-isolation-backend/README.md | 394 ++++++++++ .../codebase-grounding.md | 26 + rfc/0012-isolation-backend/topology-matrix.md | 36 + 10 files changed, 1893 insertions(+) create mode 100644 crates/openshell-isolation/BUILD.bazel create mode 100644 crates/openshell-isolation/Cargo.toml create mode 100644 crates/openshell-isolation/src/contract.rs create mode 100644 crates/openshell-isolation/src/contract/tests.rs create mode 100644 crates/openshell-isolation/src/lib.rs create mode 100644 rfc/0012-isolation-backend/README.md create mode 100644 rfc/0012-isolation-backend/codebase-grounding.md create mode 100644 rfc/0012-isolation-backend/topology-matrix.md diff --git a/AGENTS.md b/AGENTS.md index 8c88c94814..604395353c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-cli/` | CLI binary | User-facing command-line interface | | `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary | | `crates/openshell-sandbox/` | Sandbox runtime | Container supervision, policy-enforced egress routing | +| `crates/openshell-isolation/` | Isolation backend contract | RFC 0012 `IsolationBackend` trait + types; the supervisor-facing runtime contract for the boundary | | `crates/openshell-policy/` | Policy engine | Filesystem, network, process, and inference constraints | | `crates/openshell-router/` | Privacy router | Privacy-aware LLM routing | | `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage | diff --git a/Cargo.lock b/Cargo.lock index 3ae582a12a..549b1fc834 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4147,6 +4147,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "openshell-isolation" +version = "0.0.0" +dependencies = [ + "async-trait", + "openshell-core", + "tokio", +] + [[package]] name = "openshell-ocsf" version = "0.0.0" diff --git a/crates/openshell-isolation/BUILD.bazel b/crates/openshell-isolation/BUILD.bazel new file mode 100644 index 0000000000..8bc2b233e3 --- /dev/null +++ b/crates/openshell-isolation/BUILD.bazel @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +load("@crates//:defs.bzl", "aliases", "all_crate_deps") +load("@rules_rs//rs:rust_library.bzl", "rust_library") +load("@rules_rs//rs:rust_test.bzl", "rust_test") +load("@rules_rust//rust:defs.bzl", "rustfmt_test") + +rust_library( + name = "openshell-isolation", + srcs = glob(["src/**/*.rs"]), + aliases = aliases(), + visibility = ["//visibility:public"], + deps = all_crate_deps(normal = True), +) + +rust_test( + name = "openshell-isolation_test", + crate = ":openshell-isolation", + deps = all_crate_deps(normal_dev = True), +) + +rustfmt_test( + name = "rustfmt_test", + targets = [ + ":openshell-isolation", + ":openshell-isolation_test", + ], + visibility = ["//crates:__pkg__"], +) diff --git a/crates/openshell-isolation/Cargo.toml b/crates/openshell-isolation/Cargo.toml new file mode 100644 index 0000000000..be703c52c4 --- /dev/null +++ b/crates/openshell-isolation/Cargo.toml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-isolation" +description = "OpenShell Isolation Backend runtime contract (RFC 0012)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } +async-trait = "0.1" +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-isolation/src/contract.rs b/crates/openshell-isolation/src/contract.rs new file mode 100644 index 0000000000..f94212ca9e --- /dev/null +++ b/crates/openshell-isolation/src/contract.rs @@ -0,0 +1,609 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runtime-selectable Isolation Backend contract (RFC 0012). +//! +//! This module is the object-safe, runtime-selectable contract the supervisor +//! role drives. A backend registers an [`IsolationBackend`] under a +//! `backend_name`; the supervisor resolves it from a [`BackendRegistry`] +//! against the admitted backend name and advances the boundary through a fixed +//! chain of boxed states: +//! +//! ```text +//! attach topology + sandbox context -> Bound -> confirm -> Ready +//! -> start_agent -> Running +//! ``` +//! +//! Each transition consumes the prior state by value (`self: Box`), and no +//! state type has a public constructor, so a stage cannot be skipped or +//! replayed. The supervisor holds no `match`/downcast on concrete backends: the +//! registry is the only lookup by `backend_name`, and everything past it is a +//! `Box` / `Arc`. +//! +//! `attach` is atomic from the caller's perspective: it returns `Bound` or fails +//! closed, and it never binds a resource that is already bound to an active +//! boundary. Binary identity travels on every [`MediatedConnection`], resolved +//! by the backend for that exact connection; an unresolved identity denies the +//! connection and never authorizes anything. +//! +//! The contract is transport-neutral. Concrete topology implementations keep +//! their placement and coordination details behind these interfaces. + +use std::collections::HashMap; +use std::fmt; +use std::net::IpAddr; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub use openshell_core::policy::SandboxPolicy; + +/// The Isolation Backend contract version. The descriptor and the resolved +/// backend must both equal the supervisor-supported version exactly. +pub const INTERFACE_VERSION: u32 = 1; + +// ============================================================================ +// Errors +// ============================================================================ + +/// Classified failures at the common contract boundary. +/// +/// An error never advances the lifecycle or authorizes an operation. +#[derive(Debug)] +pub enum BackendError { + /// Descriptor missing, malformed, unsupported, or mismatched against admission. + Descriptor(String), + /// No backend registered for the resolved `backend_name`. + NotRegistered(String), + /// Authenticated attachment rejection (incompatible or already-bound resource). + Denied(String), + /// Boundary temporarily unavailable. + Unavailable(String), + /// Attachment-phase failure (establishment or mediation bring-up). + Attach(String), + /// Readiness confirmation failed (do not start workload code). + Confirm(String), + /// Process start or exec failure. + Process(String), + /// Abnormal boundary or workload loss, or an operation against an inactive + /// boundary. + Terminated(String), +} + +/// Coarse, machine-readable classification of a [`BackendError`] for supervisor +/// status mapping. The error's variant and message carry the structured context +/// (which operation failed). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendErrorKind { + /// Descriptor, version, or backend mismatch. + Invalid, + /// Authenticated attachment rejection. + Denied, + /// Transient inability to serve an operation. + Unavailable, + /// Attachment, confirmation, start, or runtime operation failure. + Failed, + /// Abnormal boundary/workload loss, or an operation against an inactive + /// boundary. + Terminated, +} + +impl BackendError { + /// The machine-readable kind for this error. + #[must_use] + pub fn kind(&self) -> BackendErrorKind { + match self { + Self::Descriptor(_) | Self::NotRegistered(_) => BackendErrorKind::Invalid, + Self::Denied(_) => BackendErrorKind::Denied, + Self::Unavailable(_) => BackendErrorKind::Unavailable, + Self::Attach(_) | Self::Confirm(_) | Self::Process(_) => BackendErrorKind::Failed, + Self::Terminated(_) => BackendErrorKind::Terminated, + } + } +} + +impl fmt::Display for BackendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Descriptor(m) => write!(f, "descriptor error: {m}"), + Self::NotRegistered(m) => write!(f, "backend not registered: {m}"), + Self::Denied(m) => write!(f, "attachment denied: {m}"), + Self::Unavailable(m) => write!(f, "boundary unavailable: {m}"), + Self::Attach(m) => write!(f, "attachment failed: {m}"), + Self::Confirm(m) => write!(f, "confirmation failed: {m}"), + Self::Process(m) => write!(f, "process error: {m}"), + Self::Terminated(m) => write!(f, "boundary terminated: {m}"), + } + } +} + +impl std::error::Error for BackendError {} + +/// Why an identity resolution failed. Resolution failure fails closed: the +/// mediation service denies and audits the connection; it never authorizes. +#[derive(Debug, Clone)] +pub enum ResolveError { + /// No process owns the connection (stale or unknown attribution). + NotFound, + /// Resolution attempted but could not produce trustworthy identity. + Failed(String), +} + +impl fmt::Display for ResolveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotFound => write!(f, "connection owner not found"), + Self::Failed(m) => write!(f, "identity resolution failed: {m}"), + } + } +} + +impl std::error::Error for ResolveError {} + +// ============================================================================ +// Descriptor and registry +// ============================================================================ + +/// The common topology descriptor envelope. +/// +/// The compute driver supplies one for every provisioned topology, including +/// resources prepared before sandbox assignment. The opaque payload identifies, +/// or gives the backend enough information to resolve, the exact +/// driver-provisioned resource; its protection is backend-specific. +#[derive(Debug, Clone)] +pub struct TopologyDescriptor { + /// The Isolation Backend contract version this descriptor targets. + pub version: u32, + /// The backend the supervisor must instantiate. + pub backend_name: String, + /// Backend-specific attachment data. + pub payload: Vec, +} + +/// A descriptor whose common envelope has passed registry verification. +/// +/// Minted only by [`BackendRegistry::resolve`]; no public constructor, so an +/// unverified descriptor cannot reach a backend. The type does not imply that +/// the opaque payload has been validated: the backend validates the payload and +/// atomically binds it to the sandbox context during `attach`. +pub struct VerifiedTopologyDescriptor { + descriptor: TopologyDescriptor, +} + +impl VerifiedTopologyDescriptor { + /// The verified backend name. + #[must_use] + pub fn backend_name(&self) -> &str { + &self.descriptor.backend_name + } + /// The backend-specific payload (validated by the backend at `attach`). + #[must_use] + pub fn payload(&self) -> &[u8] { + &self.descriptor.payload + } + /// The interface version. + #[must_use] + pub fn version(&self) -> u32 { + self.descriptor.version + } +} + +/// The trusted sandbox context, constructed by trusted common code after the +/// control plane assigns the resource to the admitted sandbox. +/// +/// Carries the admitted create-time policy. Approved network-policy revisions +/// are made effective by supervisor-owned network mediation, outside the +/// backend lifecycle. +pub struct SandboxContext { + /// Which sandbox this is. + pub sandbox_id: String, + /// The admitted create-time policy. + pub policy: SandboxPolicy, + /// The admitted agent workload. + pub agent: AgentSpec, +} + +/// The agent workload to run inside the boundary. +pub use crate::AgentSpec; + +/// Maps backend name to its implementation. This is the only lookup by name; +/// supervisor lifecycle never branches on a concrete backend, and resolution +/// never falls back to another backend. +#[derive(Default)] +pub struct BackendRegistry { + backends: HashMap>, +} + +impl BackendRegistry { + /// An empty registry. + #[must_use] + pub fn new() -> Self { + Self { + backends: HashMap::new(), + } + } + + /// Register a backend. Rejects a duplicate name or a backend that does not + /// speak the supervisor-supported interface version exactly. + /// + /// # Errors + /// + /// Returns [`BackendError::Descriptor`] for a duplicate `backend_name` or + /// an interface-version mismatch. + pub fn register(&mut self, backend: Arc) -> Result<(), BackendError> { + let name = backend.backend_name().to_string(); + if self.backends.contains_key(&name) { + return Err(BackendError::Descriptor(format!( + "duplicate backend name {name:?}" + ))); + } + if backend.version() != INTERFACE_VERSION { + return Err(BackendError::Descriptor(format!( + "backend {name:?} targets interface version {}, supervisor speaks {INTERFACE_VERSION}", + backend.version() + ))); + } + self.backends.insert(name, backend); + Ok(()) + } + + /// Verify the descriptor's common envelope against the admitted backend name + /// and resolve its backend. Fails closed and never falls back: + /// + /// - the descriptor's interface version must equal [`INTERFACE_VERSION`]; + /// - the descriptor's `backend_name` must equal the admitted name; + /// - a backend must be registered under that name; and + /// - the backend's version must equal [`INTERFACE_VERSION`] exactly. + /// + /// # Errors + /// + /// Returns [`BackendError::Descriptor`] for a version or admission + /// mismatch, and [`BackendError::NotRegistered`] when no backend is + /// registered for the admitted name. + pub fn resolve( + &self, + descriptor: TopologyDescriptor, + admitted_backend_name: &str, + ) -> Result<(Arc, VerifiedTopologyDescriptor), BackendError> { + if descriptor.version != INTERFACE_VERSION { + return Err(BackendError::Descriptor(format!( + "descriptor interface version {} unsupported (expected {INTERFACE_VERSION})", + descriptor.version + ))); + } + if descriptor.backend_name != admitted_backend_name { + return Err(BackendError::Descriptor(format!( + "descriptor backend {:?} does not match admitted backend {admitted_backend_name:?}", + descriptor.backend_name + ))); + } + let backend = self + .backends + .get(&descriptor.backend_name) + .ok_or_else(|| BackendError::NotRegistered(descriptor.backend_name.clone()))? + .clone(); + if backend.backend_name() != descriptor.backend_name { + return Err(BackendError::Descriptor(format!( + "registry returned backend {:?} for name {:?}", + backend.backend_name(), + descriptor.backend_name + ))); + } + if backend.version() != INTERFACE_VERSION { + return Err(BackendError::Descriptor(format!( + "backend {:?} speaks interface version {}, supervisor requires {INTERFACE_VERSION}", + descriptor.backend_name, + backend.version() + ))); + } + Ok((backend, VerifiedTopologyDescriptor { descriptor })) + } +} + +/// Establishes and operates boundaries for one admitted backend implementation. +#[async_trait] +pub trait IsolationBackend: Send + Sync { + /// The stable registered backend name. + fn backend_name(&self) -> &str; + + /// The Isolation Backend contract version this backend speaks. Matched + /// exactly against [`INTERFACE_VERSION`]; there is no capability negotiation. + fn version(&self) -> u32; + + /// Validate the opaque payload and atomically bind it to the trusted + /// sandbox context: returns `Bound` or fails closed. Never binds a resource + /// that is already bound to an active boundary. + async fn attach( + &self, + descriptor: VerifiedTopologyDescriptor, + sandbox: SandboxContext, + ) -> Result, BackendError>; +} + +// ============================================================================ +// Lifecycle states +// ============================================================================ + +/// Bound: the topology descriptor and trusted sandbox context are bound to the +/// same resource, and the mediation source is available. No untrusted workload +/// code is running. +#[async_trait] +pub trait BoundBoundary: Send { + /// The mediation service's backend-neutral source of workload connections. + /// Retained by the supervisor before consuming `Bound`. + fn network_mediation_source(&self) -> Arc; + + /// Confirm standing enforcement. How a backend establishes confidence is + /// private to that backend; confirmation fails closed. + async fn confirm(self: Box) -> Result, BackendError>; +} + +/// Ready: standing enforcement is confirmed, and the backend is prepared to +/// ensure the admitted launch-time controls are in force +/// before untrusted execution. Only agent activation is possible from here. +#[async_trait] +pub trait ReadyBoundary: Send { + /// Make the admitted agent runnable behind the boundary and return its + /// handle. `start_agent` is the sole operation that may make the admitted + /// agent runnable, and it fails closed if any `Ready` condition no longer + /// holds. Whether the backend creates the agent process or releases a held, + /// driver-provisioned execution object is backend-specific; every + /// applicable launch-time control is in force before the first untrusted + /// instruction. + async fn start_agent(self: Box) -> Result, BackendError>; +} + +/// Running: the agent is runnable behind the boundary and the returned agent +/// handle represents the admitted agent process. Exec and forwarding are available. +/// +/// All interface accessors return owned `Arc`s so a consumer can retain them +/// past any later state consumption. +pub trait RunningBoundary: Send + Sync { + /// The admitted agent process handle. + fn agent(&self) -> Arc; + /// The in-boundary exec interface. + fn exec(&self) -> Arc; + /// The loopback port-forward interface. + fn port_forward(&self) -> Arc; +} + +// ============================================================================ +// Process and exec +// ============================================================================ + +/// Placement-neutral terminal status of a boundary process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoundaryExitStatus { + /// Exited with a code. + Exited(i32), + /// Killed by a signal. + Signaled(i32), +} + +/// Placement-neutral signal to deliver to a boundary process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoundarySignal { + /// Graceful terminate. + Term, + /// Forceful kill. + Kill, + /// Interrupt. + Int, + /// Hangup. + Hup, +} + +/// A process running inside the boundary. `wait` returns one stable status +/// however many times it is called; a local PID is never the process handle. +#[async_trait] +pub trait BoundaryProcess: Send + Sync { + /// Await terminal status (stable across repeated calls). + async fn wait(&self) -> Result; + /// Deliver a signal to the process or its group. + async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError>; + /// Terminate the process and its backend-owned process group. + async fn terminate(&self) -> Result<(), BackendError>; +} + +/// A boxed async writer into a boundary process's stdin. +pub type BoundaryInput = Box; +/// A boxed async reader from a boundary process's stdout or stderr. +pub type BoundaryOutput = Box; + +/// A PTY attached to an exec session. +#[async_trait] +pub trait BoundaryTerminal: Send + Sync { + /// Resize the terminal. + async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError>; +} + +/// An owned exec session: the process handle plus its stdio and optional PTY. +/// Owning the process keeps it alive after `exec` returns. +pub struct ExecSession { + /// The spawned process. + pub process: Arc, + /// Stdin writer, if not a PTY-merged stream. + pub stdin: Option, + /// Stdout reader. + pub stdout: BoundaryOutput, + /// Stderr reader, distinct from stdout for non-PTY exec. + pub stderr: Option, + /// PTY control, present when a terminal was requested. + pub terminal: Option>, +} + +/// What to run inside the boundary via [`BoundaryExec`]. +#[derive(Debug, Clone)] +pub struct ExecSpec { + /// Program to run. + pub program: String, + /// Program arguments. + pub args: Vec, + /// Extra environment over the boundary's base. + pub env: Vec<(String, String)>, + /// Working directory, if any. + pub workdir: Option, + /// Whether to allocate a PTY. + pub pty: bool, +} + +/// In-boundary process entry, consumed by the SSH server and supervisor session. +/// +/// Like `start_agent`, every exec ensures the applicable launch-time controls +/// are in force before the new process executes its first untrusted instruction +/// and preserves the provisioned execution environment. +#[async_trait] +pub trait BoundaryExec: Send + Sync { + /// Spawn `spec` inside the boundary, returning an owned session. + async fn exec(&self, spec: ExecSpec) -> Result; +} + +// ============================================================================ +// Port forward +// ============================================================================ + +/// A loopback-only target inside the boundary, validated at construction. +#[derive(Debug, Clone)] +pub struct LoopbackTarget { + host: IpAddr, + port: u16, +} + +impl LoopbackTarget { + /// Build a loopback target, rejecting any non-loopback host. + /// + /// # Errors + /// + /// Returns [`BackendError::Process`] when `host` is not a loopback address. + pub fn new(host: IpAddr, port: u16) -> Result { + if !host.is_loopback() { + return Err(BackendError::Process(format!( + "port-forward target {host} is not loopback" + ))); + } + Ok(Self { host, port }) + } + /// The loopback host. + #[must_use] + pub fn host(&self) -> IpAddr { + self.host + } + /// The target port. + #[must_use] + pub fn port(&self) -> u16 { + self.port + } +} + +/// A bidirectional byte stream into the boundary. +pub trait DuplexStream: AsyncRead + AsyncWrite + Send + Unpin {} +impl DuplexStream for T {} + +/// An open connection into a boundary loopback target. +pub type BoundaryDuplexStream = Box; + +/// Loopback port-forward, consumed by the SSH server and supervisor session. +#[async_trait] +pub trait BoundaryPortForward: Send + Sync { + /// Connect to `target` inside the boundary. + async fn connect(&self, target: LoopbackTarget) -> Result; +} + +// ============================================================================ +// Mediation and binary identity +// ============================================================================ + +/// Executable identity for one accepted connection, resolved by the backend and +/// delivered on [`MediatedConnection`] before the mediation service evaluates +/// policy. +/// +/// A missing digest is `None`, never an empty value; policy that requires an +/// unavailable identity field cannot authorize the connection. How a backend +/// resolves identity is private to that backend; the shape and the fail-closed +/// semantics do not change. +#[derive(Debug, Clone)] +pub struct BinaryIdentity { + /// Absolute path of the executable resolved for the accepted connection. + pub binary_path: PathBuf, + /// Digest of the resolved executable object. `None` when unavailable. + pub binary_digest: Option, + /// Ancestor process binaries, nearest first. + pub ancestors: Vec, + /// Absolute script/interpreter paths drawn from the process cmdlines. + /// Diagnostic context; never authorizes. + pub cmdline_paths: Vec, +} + +/// A SHA-256 digest, kept typed so the identity field is not coupled to its +/// textual encoding or forced to repeat the algorithm in its name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Sha256Digest([u8; 32]); + +impl Sha256Digest { + /// Return the raw digest bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Display for Sha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl FromStr for Sha256Digest { + type Err = ResolveError; + + fn from_str(value: &str) -> Result { + if value.len() != 64 || !value.is_ascii() { + return Err(ResolveError::Failed( + "SHA-256 digest must contain 64 hexadecimal characters".to_string(), + )); + } + let mut bytes = [0_u8; 32]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| { + ResolveError::Failed("SHA-256 digest contains non-hexadecimal data".to_string()) + })?; + } + Ok(Self(bytes)) + } +} + +/// A workload connection delivered to the mediation service, carrying the +/// identity-resolution result for that connection. +/// +/// An `Err` identity denies the connection and is audited; it never authorizes +/// anything. +pub struct MediatedConnection { + /// The workload connection stream. + pub stream: BoundaryDuplexStream, + /// Executable identity, resolved by the backend for this connection. + pub binary_identity: Result, +} + +/// A logical per-boundary stream of workload connections, consumed by the +/// mediation service wherever that service runs. +/// +/// It may wrap a dedicated listener or a demultiplexed view over shared +/// transport; how it reaches a co-located proxy, a sidecar, or a shared +/// mediation service is backend-private. A trusted backend component associates +/// every returned connection with its active boundary without relying solely on +/// a transport tuple or workload-provided identifier. An `Err` from `accept` +/// means the source itself is unusable and fails the boundary closed. +#[async_trait] +pub trait NetworkMediationSource: Send + Sync { + /// Await the next mediated workload connection. + async fn accept(&self) -> Result; +} + +#[cfg(test)] +mod tests; diff --git a/crates/openshell-isolation/src/contract/tests.rs b/crates/openshell-isolation/src/contract/tests.rs new file mode 100644 index 0000000000..645bb7edee --- /dev/null +++ b/crates/openshell-isolation/src/contract/tests.rs @@ -0,0 +1,716 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Conformance harness for the runtime-selectable contract. +//! +//! Two materially different mock backends (`Primary`, `Secondary`) with +//! distinct concrete state structs (each generic over a marker, so each kind +//! monomorphizes to its own types) prove the registry holds heterogeneous +//! backends behind `dyn` with no enum over concrete state, and that one driver +//! runs both unchanged. + +use std::marker::PhantomData; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::*; + +// --------------------------------------------------------------------------- +// Marker kinds: two materially different backends. +// --------------------------------------------------------------------------- + +trait MockKind: Send + Sync + 'static { + const BACKEND_ID: &'static str; + /// Whether this backend can produce a binary digest (a heterogeneity axis: + /// one backend resolves a full identity, the other resolves path-only). + const HAS_DIGEST: bool; +} + +struct Primary; +impl MockKind for Primary { + const BACKEND_ID: &'static str = "mock-primary"; + const HAS_DIGEST: bool = true; +} + +struct Secondary; +impl MockKind for Secondary { + const BACKEND_ID: &'static str = "mock-secondary"; + const HAS_DIGEST: bool = false; +} + +// --------------------------------------------------------------------------- +// Runtime interfaces (shared across kinds where behavior is identical). +// --------------------------------------------------------------------------- + +struct MockProcess { + status: BoundaryExitStatus, + alive: AtomicBool, + signals: Mutex>, +} + +impl MockProcess { + fn new() -> Arc { + Arc::new(Self { + status: BoundaryExitStatus::Exited(0), + alive: AtomicBool::new(true), + signals: Mutex::new(Vec::new()), + }) + } +} + +#[async_trait] +impl BoundaryProcess for MockProcess { + async fn wait(&self) -> Result { + // Stable across repeated calls. + self.alive.store(false, Ordering::SeqCst); + Ok(self.status) + } + async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError> { + if !self.alive.load(Ordering::SeqCst) { + return Err(BackendError::Terminated("process has exited".to_string())); + } + self.signals.lock().unwrap().push(signal); + Ok(()) + } + async fn terminate(&self) -> Result<(), BackendError> { + self.alive + .swap(false, Ordering::SeqCst) + .then_some(()) + .ok_or_else(|| BackendError::Terminated("process has exited".to_string())) + } +} + +/// Mediation source: hands the mediation service a connection carrying its +/// per-connection identity-resolution result. +struct MockSource(PhantomData); + +#[async_trait] +impl NetworkMediationSource for MockSource { + async fn accept(&self) -> Result { + let (near, _far) = tokio::io::duplex(64); + Ok(MediatedConnection { + stream: Box::new(near), + binary_identity: Ok(BinaryIdentity { + binary_path: PathBuf::from("/usr/bin/agent"), + binary_digest: K::HAS_DIGEST + .then(|| "00".repeat(32).parse().expect("valid digest")), + ancestors: vec![], + cmdline_paths: vec![], + }), + }) + } +} + +/// An source whose backend cannot attribute the connection: the connection is +/// still delivered, carrying `Err`, so the mediation service denies and audits +/// it. It never authorizes anything. +struct UnattributedSource; + +#[async_trait] +impl NetworkMediationSource for UnattributedSource { + async fn accept(&self) -> Result { + let (near, _far) = tokio::io::duplex(64); + Ok(MediatedConnection { + stream: Box::new(near), + binary_identity: Err(ResolveError::Failed("hash unavailable".to_string())), + }) + } +} + +struct MockExec; + +struct MockTerminal { + size: Mutex>, +} + +#[async_trait] +impl BoundaryTerminal for MockTerminal { + async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError> { + *self.size.lock().unwrap() = Some((cols, rows)); + Ok(()) + } +} + +#[async_trait] +impl BoundaryExec for MockExec { + async fn exec(&self, spec: ExecSpec) -> Result { + let (_near, far) = tokio::io::duplex(64); + let (out_r, _out_w) = tokio::io::duplex(64); + let (err_r, _err_w) = tokio::io::duplex(64); + let stdin: BoundaryInput = Box::new(far); + let stderr: BoundaryOutput = Box::new(err_r); + let terminal: Arc = Arc::new(MockTerminal { + size: Mutex::new(None), + }); + Ok(ExecSession { + process: MockProcess::new(), + stdin: (!spec.pty).then_some(stdin), + stdout: Box::new(out_r), + stderr: (!spec.pty).then_some(stderr), + terminal: spec.pty.then_some(terminal), + }) + } +} + +struct MockPortForward; + +#[async_trait] +impl BoundaryPortForward for MockPortForward { + async fn connect(&self, _target: LoopbackTarget) -> Result { + let (near, far) = tokio::io::duplex(64); + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut far = far; + let mut buf = [0u8; 4]; + if far.read_exact(&mut buf).await.is_ok() { + let _ = far.write_all(&buf).await; + } + }); + Ok(Box::new(near)) + } +} + +// --------------------------------------------------------------------------- +// Boxed lifecycle states (distinct concrete struct per kind). +// --------------------------------------------------------------------------- + +struct MockBound { + source: Arc>, +} +struct MockReady { + _k: PhantomData, +} +struct MockRunning { + process: Arc, + exec: Arc, + port_forward: Arc, + _k: PhantomData, +} + +#[async_trait] +impl BoundBoundary for MockBound { + fn network_mediation_source(&self) -> Arc { + self.source.clone() + } + async fn confirm(self: Box) -> Result, BackendError> { + Ok(Box::new(MockReady:: { _k: PhantomData })) + } +} + +#[async_trait] +impl ReadyBoundary for MockReady { + async fn start_agent(self: Box) -> Result, BackendError> { + Ok(Box::new(MockRunning:: { + process: MockProcess::new(), + exec: Arc::new(MockExec), + port_forward: Arc::new(MockPortForward), + _k: PhantomData, + })) + } +} + +impl RunningBoundary for MockRunning { + fn agent(&self) -> Arc { + self.process.clone() + } + fn exec(&self) -> Arc { + self.exec.clone() + } + fn port_forward(&self) -> Arc { + self.port_forward.clone() + } +} + +/// One backend per boundary resource: `attach` is atomic and never binds a +/// resource that is already bound to an active boundary, so a second attach +/// against the same mock resource is `Denied`. +struct MockBackend { + attached: AtomicBool, + _k: PhantomData, +} + +impl MockBackend { + fn new() -> Self { + Self { + attached: AtomicBool::new(false), + _k: PhantomData, + } + } +} + +#[async_trait] +impl IsolationBackend for MockBackend { + fn backend_name(&self) -> &'static str { + K::BACKEND_ID + } + fn version(&self) -> u32 { + INTERFACE_VERSION + } + async fn attach( + &self, + descriptor: VerifiedTopologyDescriptor, + sandbox: SandboxContext, + ) -> Result, BackendError> { + assert_eq!(descriptor.backend_name(), K::BACKEND_ID); + assert!(!sandbox.sandbox_id.is_empty()); + if self.attached.swap(true, Ordering::SeqCst) { + return Err(BackendError::Denied( + "resource is already bound to an active boundary".to_string(), + )); + } + Ok(Box::new(MockBound:: { + source: Arc::new(MockSource(PhantomData)), + })) + } +} + +/// A backend that speaks the wrong contract version; registration must reject it. +struct WrongVersionBackend; + +#[async_trait] +impl IsolationBackend for WrongVersionBackend { + fn backend_name(&self) -> &'static str { + "mock-wrong-version" + } + fn version(&self) -> u32 { + INTERFACE_VERSION + 1 + } + async fn attach( + &self, + _descriptor: VerifiedTopologyDescriptor, + _sandbox: SandboxContext, + ) -> Result, BackendError> { + unreachable!("must never be resolved") + } +} + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- + +fn registry() -> BackendRegistry { + let mut reg = BackendRegistry::new(); + reg.register(Arc::new(MockBackend::::new())) + .expect("register primary"); + reg.register(Arc::new(MockBackend::::new())) + .expect("register secondary"); + reg +} + +fn descriptor(backend_name: &str) -> TopologyDescriptor { + TopologyDescriptor { + version: INTERFACE_VERSION, + backend_name: backend_name.to_string(), + payload: vec![], + } +} + +fn sandbox_ctx() -> SandboxContext { + SandboxContext { + sandbox_id: "sb-1".to_string(), + policy: SandboxPolicy { + version: 1, + filesystem: openshell_core::policy::FilesystemPolicy::default(), + network: openshell_core::policy::NetworkPolicy::default(), + landlock: openshell_core::policy::LandlockPolicy::default(), + process: openshell_core::policy::ProcessPolicy::default(), + }, + agent: AgentSpec { + program: "/bin/true".to_string(), + args: vec![], + workdir: None, + timeout_secs: 0, + interactive: false, + }, + } +} + +/// The backend-independent supervisor sequence. Identical for every backend: +/// this is the proof that adding a backend needs no supervisor lifecycle change. +async fn drive( + reg: &BackendRegistry, + descriptor: TopologyDescriptor, + admitted: &str, +) -> Result, BackendError> { + let (backend, verified) = reg.resolve(descriptor, admitted)?; + let bound = backend.attach(verified, sandbox_ctx()).await?; + // The mediation source is retained before consuming `Bound` and stays + // usable across the confirm/start transitions. + let _ingress = bound.network_mediation_source(); + let ready = bound.confirm().await?; + ready.start_agent().await +} + +// --------------------------------------------------------------------------- +// Registry and descriptor. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn registry_selects_correct_backend() { + let reg = registry(); + let (f, _v) = reg + .resolve(descriptor("mock-secondary"), "mock-secondary") + .expect("resolve"); + assert_eq!(f.backend_name(), "mock-secondary"); +} + +#[test] +fn registry_rejects_duplicate_registration() { + let mut reg = BackendRegistry::new(); + reg.register(Arc::new(MockBackend::::new())) + .expect("first"); + let err = reg + .register(Arc::new(MockBackend::::new())) + .expect_err("duplicate must fail"); + assert!(matches!(err, BackendError::Descriptor(_))); +} + +#[test] +fn registry_rejects_wrong_backend_version() { + let mut reg = BackendRegistry::new(); + let err = reg + .register(Arc::new(WrongVersionBackend)) + .expect_err("wrong version must fail"); + assert_eq!(err.kind(), BackendErrorKind::Invalid); +} + +#[test] +fn registry_rejects_unknown_backend() { + let reg = registry(); + let err = reg + .resolve(descriptor("nope"), "nope") + .map(|_| ()) + .expect_err("unknown must fail"); + assert!(matches!(err, BackendError::NotRegistered(_))); +} + +#[test] +fn registry_rejects_descriptor_admission_mismatch_without_fallback() { + let reg = registry(); + // Descriptor names primary, admission says secondary: must fail, and must + // not silently fall back to either backend. + let err = reg + .resolve(descriptor("mock-primary"), "mock-secondary") + .map(|_| ()) + .expect_err("mismatch must fail"); + assert!(matches!(err, BackendError::Descriptor(_))); +} + +#[test] +fn registry_rejects_unsupported_version() { + let reg = registry(); + let mut d = descriptor("mock-primary"); + d.version = INTERFACE_VERSION + 1; + let err = reg + .resolve(d, "mock-primary") + .map(|_| ()) + .expect_err("bad version must fail"); + assert!(matches!(err, BackendError::Descriptor(_))); +} + +// --------------------------------------------------------------------------- +// Lifecycle: one driver, two heterogeneous backends, no consumer change. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn one_driver_runs_both_backends() { + let reg = registry(); + // The exact same driver code runs a backend with distinct concrete state + // structs; the registry holds them behind `dyn`, no enum. + let primary = drive(®, descriptor("mock-primary"), "mock-primary") + .await + .expect("primary lifecycle"); + let secondary = drive(®, descriptor("mock-secondary"), "mock-secondary") + .await + .expect("secondary lifecycle"); + + // Both expose a usable agent process handle past start_agent. + assert_eq!( + primary.agent().wait().await.expect("wait"), + BoundaryExitStatus::Exited(0) + ); + assert_eq!( + secondary.agent().wait().await.expect("wait"), + BoundaryExitStatus::Exited(0) + ); +} + +#[tokio::test] +async fn one_boundary_termination_does_not_change_another_boundary() { + let reg = registry(); + let primary = drive(®, descriptor("mock-primary"), "mock-primary") + .await + .expect("primary lifecycle"); + let secondary = drive(®, descriptor("mock-secondary"), "mock-secondary") + .await + .expect("secondary lifecycle"); + + primary + .agent() + .terminate() + .await + .expect("terminate primary"); + secondary + .agent() + .signal(BoundarySignal::Term) + .await + .expect("secondary remains active"); +} + +#[tokio::test] +async fn attach_never_binds_an_already_bound_resource() { + let reg = registry(); + // First attach binds the mock resource. + drive(®, descriptor("mock-primary"), "mock-primary") + .await + .expect("first lifecycle"); + // A second attach against the same active boundary must be denied, not + // silently create a second binding. + let err = drive(®, descriptor("mock-primary"), "mock-primary") + .await + .map(|_| ()) + .expect_err("second attach must fail"); + assert_eq!(err.kind(), BackendErrorKind::Denied); +} + +#[tokio::test] +async fn runtime_interfaces_survive_lifecycle_consumption() { + let reg = registry(); + let (backend, verified) = reg + .resolve(descriptor("mock-primary"), "mock-primary") + .expect("resolve"); + let bound = backend + .attach(verified, sandbox_ctx()) + .await + .expect("attach"); + + // Retain the source at Bound, then consume the bound state with confirm. + // The retained Arc must remain usable afterward. + let source = bound.network_mediation_source(); + let ready = bound.confirm().await.expect("confirm"); + let _running = ready.start_agent().await.expect("start"); + + let conn = source.accept().await.expect("accept after consumption"); + let identity = conn.binary_identity.expect("identity resolves"); + assert_eq!(identity.binary_path, PathBuf::from("/usr/bin/agent")); +} + +// --------------------------------------------------------------------------- +// Process and I/O. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn agent_process_survives_and_wait_is_stable() { + let reg = registry(); + let running = drive(®, descriptor("mock-primary"), "mock-primary") + .await + .expect("lifecycle"); + let agent = running.agent(); + // Survives start_agent returning; wait is stable across repeated calls. + assert_eq!( + agent.wait().await.expect("wait 1"), + BoundaryExitStatus::Exited(0) + ); + assert_eq!( + agent.wait().await.expect("wait 2"), + BoundaryExitStatus::Exited(0) + ); + assert!(matches!( + agent.signal(BoundarySignal::Term).await, + Err(BackendError::Terminated(_)) + )); +} + +#[tokio::test] +async fn every_signal_reaches_the_backend_unchanged() { + let process = MockProcess::new(); + for signal in [ + BoundarySignal::Term, + BoundarySignal::Kill, + BoundarySignal::Int, + BoundarySignal::Hup, + ] { + process.signal(signal).await.expect("signal"); + } + assert_eq!( + *process.signals.lock().unwrap(), + vec![ + BoundarySignal::Term, + BoundarySignal::Kill, + BoundarySignal::Int, + BoundarySignal::Hup, + ] + ); +} + +#[tokio::test] +async fn normal_and_signaled_exit_are_distinct_and_stable() { + let signaled = MockProcess { + status: BoundaryExitStatus::Signaled(9), + alive: AtomicBool::new(false), + signals: Mutex::new(Vec::new()), + }; + assert_eq!( + signaled.wait().await.expect("first wait"), + BoundaryExitStatus::Signaled(9) + ); + assert_eq!( + signaled.wait().await.expect("second wait"), + BoundaryExitStatus::Signaled(9) + ); + assert_ne!( + signaled.wait().await.expect("third wait"), + BoundaryExitStatus::Exited(137) + ); +} + +#[tokio::test] +async fn exec_session_owns_its_process_and_streams() { + let reg = registry(); + let running = drive(®, descriptor("mock-primary"), "mock-primary") + .await + .expect("lifecycle"); + let session = running + .exec() + .exec(ExecSpec { + program: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "true".to_string()], + env: vec![], + workdir: None, + pty: false, + }) + .await + .expect("exec"); + // The exec'd process survives `exec` returning, and stdout/stderr are distinct. + assert!(session.stderr.is_some()); + assert!(session.stdin.is_some()); + assert_eq!( + session.process.wait().await.expect("exec wait"), + BoundaryExitStatus::Exited(0) + ); +} + +#[tokio::test] +async fn pty_exec_merges_output_and_supports_resize() { + let session = MockExec + .exec(ExecSpec { + program: "/bin/sh".to_string(), + args: vec![], + env: vec![], + workdir: None, + pty: true, + }) + .await + .expect("pty exec"); + assert!(session.stdin.is_none()); + assert!(session.stderr.is_none()); + session + .terminal + .expect("terminal") + .resize(120, 40) + .await + .expect("resize"); +} + +#[tokio::test] +async fn port_forward_rejects_non_loopback() { + let target = LoopbackTarget::new("8.8.8.8".parse().unwrap(), 53); + assert!(target.is_err()); + let loopback = LoopbackTarget::new("127.0.0.1".parse().unwrap(), 8080).expect("loopback ok"); + assert_eq!(loopback.port(), 8080); +} + +#[tokio::test] +async fn validated_port_forward_stream_remains_usable() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let target = LoopbackTarget::new("127.0.0.1".parse().unwrap(), 8080).unwrap(); + let mut stream = MockPortForward.connect(target).await.expect("connect"); + stream.write_all(b"ping").await.expect("write"); + let mut response = [0_u8; 4]; + stream.read_exact(&mut response).await.expect("read"); + assert_eq!(&response, b"ping"); +} + +// --------------------------------------------------------------------------- +// Mediation and binary identity. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn mediated_connection_carries_identity_for_that_connection() { + let reg = registry(); + let (backend, verified) = reg + .resolve(descriptor("mock-primary"), "mock-primary") + .expect("resolve"); + let bound = backend + .attach(verified, sandbox_ctx()) + .await + .expect("attach"); + let conn = bound + .network_mediation_source() + .accept() + .await + .expect("accept"); + let identity = conn.binary_identity.expect("identity resolves"); + assert_eq!(identity.binary_path, PathBuf::from("/usr/bin/agent")); + // A missing digest is `None`, never an empty value. + assert_eq!( + identity.binary_digest.expect("digest").to_string(), + "00".repeat(32) + ); +} + +#[tokio::test] +async fn missing_digest_is_none_never_empty() { + // The secondary backend resolves path-only identity: the digest is `None`, + // so policy that requires a digest cannot authorize the connection. + let source = MockSource::(PhantomData); + let conn = source.accept().await.expect("accept"); + let identity = conn.binary_identity.expect("identity resolves"); + assert!(identity.binary_digest.is_none()); +} + +#[tokio::test] +async fn unresolved_identity_travels_with_the_connection_and_fails_closed() { + // Attribution failure does not tear down the source: the connection is + // delivered carrying `Err`, and the mediation service denies it. + let source = UnattributedSource; + let conn = source.accept().await.expect("accept"); + assert!(conn.binary_identity.is_err()); +} + +// --------------------------------------------------------------------------- +// Errors. +// --------------------------------------------------------------------------- + +#[test] +fn error_kinds_map_to_supervisor_status_classes() { + assert_eq!( + BackendError::Descriptor("x".into()).kind(), + BackendErrorKind::Invalid + ); + assert_eq!( + BackendError::NotRegistered("x".into()).kind(), + BackendErrorKind::Invalid + ); + assert_eq!( + BackendError::Denied("x".into()).kind(), + BackendErrorKind::Denied + ); + assert_eq!( + BackendError::Unavailable("x".into()).kind(), + BackendErrorKind::Unavailable + ); + assert_eq!( + BackendError::Attach("x".into()).kind(), + BackendErrorKind::Failed + ); + assert_eq!( + BackendError::Confirm("x".into()).kind(), + BackendErrorKind::Failed + ); + assert_eq!( + BackendError::Terminated("x".into()).kind(), + BackendErrorKind::Terminated + ); +} diff --git a/crates/openshell-isolation/src/lib.rs b/crates/openshell-isolation/src/lib.rs new file mode 100644 index 0000000000..d7f4e32183 --- /dev/null +++ b/crates/openshell-isolation/src/lib.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The `OpenShell` **Isolation Backend** runtime contract (RFC 0012). +//! +//! An isolation backend establishes and enforces a workload's isolation boundary; +//! the supervisor role drives it through one contract. The supervisor-facing +//! contract lives in [`contract`]: an object-safe, runtime-selectable backend +//! plus a fixed chain of boxed lifecycle states the supervisor advances without +//! branching on where the boundary sits. The same calls work whether the +//! boundary lives in the agent's container (the in-pod backend) or further out +//! (a microVM, a node daemon, a separate pod). +//! +//! The backend establishes standing enforcement before untrusted code runs and +//! ensures launch-time controls are in force before each process's first +//! untrusted instruction. It also exposes process operations and supplies +//! workload egress to supervisor-owned network mediation. +//! +//! # Ordering is a security property +//! +//! The lifecycle states run in order: attach -> Bound -> confirm -> Ready -> +//! `start_agent` -> Running. Nothing untrusted runs inside the boundary until it +//! is confirmed ready. This is enforced *by construction*: each transition +//! consumes the prior state by value, and no state type has a public +//! constructor, so the supervisor cannot skip a stage or run a workload before +//! [`contract::ReadyBoundary`] exists. +//! +//! [`AgentSpec`] is shared between the workload definition the supervisor +//! submits and the [`contract::SandboxContext`] that `attach` binds to a +//! boundary. + +/// The agent workload to run inside the boundary. +/// +/// Carried by [`contract::SandboxContext`] so a backend's `start_agent` takes no +/// spec; the bound boundary already carries what runs inside it. +#[derive(Debug, Clone)] +pub struct AgentSpec { + /// Entrypoint program. + pub program: String, + /// Entrypoint arguments. + pub args: Vec, + /// Working directory for the entrypoint, if any. + pub workdir: Option, + /// Wall-clock timeout for the entrypoint in seconds (0 = no timeout). + pub timeout_secs: u64, + /// Whether the entrypoint runs interactively (inherits the parent pgrp). + pub interactive: bool, +} + +pub mod contract; diff --git a/rfc/0012-isolation-backend/README.md b/rfc/0012-isolation-backend/README.md new file mode 100644 index 0000000000..07bbc4dbab --- /dev/null +++ b/rfc/0012-isolation-backend/README.md @@ -0,0 +1,394 @@ +--- +authors: + - "@jganoff" +state: review +links: + - https://github.com/NVIDIA/OpenShell/issues/1737 + - https://github.com/NVIDIA/OpenShell/pull/2048 + - https://github.com/NVIDIA/OpenShell/issues/899 + - https://github.com/NVIDIA/OpenShell/issues/981 + - https://github.com/NVIDIA/OpenShell/issues/1511 + - https://github.com/NVIDIA/OpenShell/issues/1650 + - https://github.com/NVIDIA/OpenShell/issues/1680 + - https://github.com/NVIDIA/OpenShell/pull/2606 +--- + +# RFC 0012 - Isolation Backend Interface + +## Summary + +Today the supervisor both builds the workload's isolation boundary and applies its network policy. Because the supervisor runs inside the agent container, the privilege needed to build that boundary sits beside the code it confines. This RFC moves boundary construction and process operations behind a pluggable **Isolation Backend**. The supervisor continues to apply approved network policy through network mediation. + +The compute driver provisions the workload and trusted components. The logical supervisor is the trusted bridge between the gateway and the workload: it maintains the gateway connection, handles authorized requests, and drives the backend. The backend establishes the isolation controls, manages workload processes, and routes egress to network mediation. The same lifecycle supports today's in-pod implementation and future delegated implementations without topology-specific supervisor paths. + +## Motivation + +Boundary construction is embedded in the supervisor, so moving it anywhere else means changing the supervisor. That placement creates three problems: + +- A compromise reaches the boundary-building privilege in the same container. +- Building the boundary inside the agent container requires capabilities that conflict with restricted deployments. Delegating construction removes that requirement but does not guarantee Pod Security Standards compliance. See [codebase-grounding.md](./codebase-grounding.md) and #899 for background. +- Each new placement adds another branch to the supervisor. + +All three come from coupling boundary construction to boundary operation. A common interface lets deployments move privilege without changing the supervisor. + +## Non-goals + +- **Implementing a delegated backend.** Each topology requires its own design and implementation. +- **Changing authorization.** [RFC 0001](../0001-core-architecture/README.md) owns control-plane and sandbox identity. A delegated backend must still authenticate callers and scope them to one boundary. +- **Standardizing backend-internal component coordination.** A backend may coordinate helper, sidecar, or interception processes behind one lifecycle; how those components cooperate is backend-specific, not contract surface. +- **Changing gateway lifecycle or public status.** This RFC adds no gateway activation operation, public phase, or status API, and it does not define how a boundary's effective isolation model is surfaced to operators. + +## Proposal + +The mental model has three roles: + +- The **compute driver** provisions the sandbox instance according to the selected placement of the workload and trusted isolation components. That placement is the **topology**. +- The **Isolation Backend** establishes and operates the topology-specific controls around the workload. It also routes workload egress to network mediation and provides process operations. +- The **logical supervisor** is the trusted control-plane bridge between the gateway and the workload. It drives the backend, handles authorized gateway requests, and applies approved network policy through network mediation. + +Together, network policy, filesystem isolation, syscall filtering, and sandbox identity form the workload's isolation boundary. The roles above enforce that boundary and may run in one process or across several trusted components. Their placement does not change the contract. + +Each active boundary has at most one logical supervisor, which may span multiple coupled processes. The backend routes all workload egress through a per-boundary source, and the supervisor consumes that source. Internal delegation and transport remain topology-private. + +[RFC 0001](../0001-core-architecture/README.md) continues to own sandbox authentication and authorization. In this contract, sandbox identity means binding the authenticated sandbox context to the isolation boundary. + +Admission selects the sandbox's topology and determines its trusted context. The compute driver sets up the topology and gives the logical supervisor a `TopologyDescriptor` describing what it provisioned. The supervisor uses the descriptor to attach the matching Isolation Backend. The backend prepares the required controls before the agent starts. + +```mermaid +flowchart TB + Gateway["Gateway"] -->|"create sandbox"| Driver["Compute driver"] + + subgraph Topology["Driver-provisioned topology (placement varies)"] + Supervisor["Supervisor"] + Backend["Isolation Backend (may coordinate components)"] + subgraph Boundary["Isolation boundary"] + Mediator["Network mediation"] + subgraph Execution["Workload execution environment"] + Workload["Workload"] + end + end + + Supervisor -->|"drives contract"| Backend + Backend -->|"establishes and confirms"| Boundary + Backend -.->|"routes all workload egress to"| Mediator + Supervisor -.->|"applies network policy through"| Mediator + Backend -->|"after Ready: makes admitted agent runnable"| Workload + Workload ==>|"only egress"| Mediator + end + + Driver -->|"resources + TopologyDescriptor"| Supervisor + Mediator -->|"allowed egress"| Egress["Egress"] +``` + +In the in-pod topology, the supervisor drives a backend implemented in the same process. Other topologies may delegate backend operations without changing the supervisor lifecycle. + +A boundary is active from successful `attach` until normal backend cleanup releases the binding or the topology's trusted cleanup path invalidates it. A backend may coordinate multiple trusted helper or interception processes for that boundary. The backend owns the active-boundary binding; the compute driver owns the sandbox instance and topology lifecycle. + +### Contract invariants + +Six invariants hold for every boundary: + +1. Workload egress is denied except through network mediation for the boundary's lifetime. +2. No untrusted instruction executes until every admitted control applicable to that process is in force. +3. An operation is authorized only when the complete effective policy permits it; network operations are decided through network mediation. There is no silent weakening. +4. Agent startup, `exec`, and forwarding occur only through the active backend, and every workload process remains in the compute driver's provisioned execution environment. +5. Shared infrastructure preserves strict per-boundary lifecycle, policy, identity, enforcement, and cleanup isolation. +6. If the logical supervisor is lost, the boundary remains under its last confirmed enforcement state while supervisor-dependent operations fail closed. Loss of required enforcement ends `Running` and terminates all workload processes within a documented bound; detection and termination may be performed by a trusted node or control-plane actor. Network-mediation unavailability denies outbound connections and never enables direct egress. + +Each backend states its termination bound in its implementation documentation. Loss of the logical supervisor means loss of the components holding the backend lifecycle, not loss of the gateway connection; gateway disconnection follows RFC 0001's reconnection semantics. + +### Provisioning + +Provisioning runs on the control plane, and three rules hold in every topology: + +1. **Admission selects the topology** from trusted deployment configuration, not `SandboxPolicy`, and records its required backend. The `TopologyDescriptor` supplied by the compute driver must name that backend, and resolution never falls back to another backend. +2. **The compute driver provisions the topology** and anything the selected backend needs. +3. **The backend establishes standing enforcement before untrusted code runs**, during provisioning or `attach`, depending on the backend. + +If a topology depends on cluster-scoped coverage or registration, admission verifies that the prerequisite covers the boundary's placement before untrusted code runs. + +Every topology provides a trusted cleanup path that does not depend on logical-supervisor availability. + +A compute driver may provision a resource and `TopologyDescriptor` before the control plane assigns it to a sandbox. No untrusted workload runs while the resource is unassigned. After claim or assignment produces a trusted `SandboxContext`, the supervisor calls `attach`; the backend either binds that context to the prepared resource and returns `Bound`, or rejects it as incompatible. Pool creation, claim, reset, release, and recycling remain outside this contract. + +### The topology descriptor + +The driver supplies a descriptor for every topology admitted to this contract, including in-pod and resources prepared before assignment. The common envelope names the backend and carries an opaque payload. + +```rust +struct TopologyDescriptor { + backend_name: String, + version: u32, + payload: Vec, +} +``` + +`version` is the Isolation Backend interface version. Backend name and version match exactly; this contract does not negotiate compatibility ranges. The descriptor is transport-neutral. Provisioning supplies it to the supervisor before `attach`; how it is transported is topology-specific and outside this contract, and every transport preserves one property: workload-controlled input cannot select or modify the descriptor. + +The opaque payload identifies, or gives the backend enough information to resolve, the exact driver-provisioned resource. It may also carry topology-specific endpoint or helper-role information; there are no common topology or role fields. + +Common verification requires: + +- the descriptor's `backend_name` matches the backend required by the admitted topology; +- the descriptor's version is one the supervisor supports, and the resolved backend reports that same version; and +- `SandboxContext` is constructed after the control plane assigns the resource to the admitted sandbox, using authenticated control-plane and trusted supervisor state. + +The supervisor validates the descriptor's common fields and produces a `VerifiedTopologyDescriptor`, then resolves its `backend_name` and version without fallback. Verification does not imply that the opaque payload is valid; the selected backend validates it and atomically binds the provisioned resource to the trusted `SandboxContext` during `attach`. Any failure rejects the sandbox. + +### The lifecycle + +The contract does not prescribe enforcement mechanisms; it standardizes how the supervisor drives whichever backend a deployment admits. + +A backend registers under a `backend_name` and version. The supervisor attaches to the admitted topology and drives the boundary through a fixed sequence of states. Each transition consumes the prior state, so the supervisor cannot skip a stage or invoke a later transition through an earlier handle. The Rust names are illustrative; the states and their semantics are normative. + +```text +attach topology + sandbox context -> Bound -> confirm -> Ready -> start_agent -> Running +``` + +```rust +#[async_trait] +trait IsolationBackend: Send + Sync { + fn backend_name(&self) -> &str; + fn version(&self) -> u32; + + async fn attach( + &self, + descriptor: VerifiedTopologyDescriptor, + sandbox: SandboxContext, + ) -> Result, BackendError>; +} + +struct SandboxContext { + sandbox_id: SandboxId, + policy: SandboxPolicy, + agent: AgentSpec, +} + +#[async_trait] +trait BoundBoundary: Send { + fn network_mediation_source(&self) -> Arc; + + async fn confirm( + self: Box, + ) -> Result, BackendError>; +} + +#[async_trait] +trait ReadyBoundary: Send { + async fn start_agent( + self: Box, + ) -> Result, BackendError>; +} + +#[async_trait] +trait RunningBoundary: Send + Sync { + fn agent(&self) -> Arc; + fn exec(&self) -> Arc; + fn port_forward(&self) -> Arc; +} +``` + +`AgentSpec` carries the complete admitted agent launch specification, including command, arguments, working directory, timeout, and interactive mode. + +`SandboxContext` carries the admitted create-time policy. [RFC 0002](../0002-agent-driven-policy-management/README.md) defines how network-policy revisions are proposed and approved. Approved revisions reach the supervisor through the existing [`GetSandboxConfig`](../../proto/sandbox.proto) gateway-supervisor contract, described in the [gateway](../../architecture/gateway.md) and [sandbox](../../architecture/sandbox.md#policy-revision-acknowledgement) architecture. The supervisor makes approved network-policy revisions effective through network mediation. If an approved network-policy revision cannot be loaded, it never becomes effective; the configured rejection posture retains the last valid generation or denies network access until a valid generation is loaded. + +The states have normative meanings: + +- **Bound:** the topology descriptor and trusted sandbox context are bound to the same resource, and the network-mediation source is available. No untrusted workload code is running. +- **Ready:** the backend has confirmed standing enforcement for this concrete boundary and is prepared to apply the admitted launch-time controls before untrusted execution. +- **Running:** `start_agent` has made the admitted agent runnable and returned `RunningBoundary`. Every applicable launch-time control was in force before the first untrusted instruction. Whether the backend creates the agent process or releases a held, driver-provisioned execution object is backend-specific; the contract fixes the ordering, not the mechanism. + +`confirm` is the pre-launch commit point. The supervisor calls it only after connecting the boundary's network-mediation source to network mediation. The backend confirms standing enforcement for the concrete boundary and may rely on a trusted provisioning-time or out-of-pod signal tied to that boundary's placement, but not on general placement health alone. + +`attach` rejects a resource already bound to an active boundary. A boundary that cannot enforce the complete admitted policy does not reach `Ready`: the backend fails `attach` or `confirm`, or the supervisor fails network-mediation initialization. + +**Standing enforcement** is established independently of a workload process. **Launch-time controls** must be in force before a process executes its first untrusted instruction. Both `start_agent` and `BoundaryExec::exec` enforce this ordering and preserve the provisioned execution environment. + +`start_agent` is the sole operation that may make the admitted agent runnable. The backend may create or release the process, but workload-controlled code cannot run before `start_agent` applies the required controls. + +`RunningBoundary::agent()` returns a handle for the admitted agent process. Processes started through `BoundaryExec` run in the same boundary and have their own process handles. Every workload process remains within the provisioned execution environment. Any exit of the admitted agent ends `Running`; the backend then terminates every remaining workload process within that environment and rejects further runtime operations, except `wait` as defined below. + +### Runtime operations + +```rust +#[async_trait] +trait BoundaryProcess: Send + Sync { // the agent, or a process started via exec + async fn wait(&self) -> Result; // one stable result where process-exit observation is retained + async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError>; + async fn terminate(&self) -> Result<(), BackendError>; // this process and its owned process group +} + +#[async_trait] +trait BoundaryExec: Send + Sync { + async fn exec(&self, spec: ExecSpec) -> Result; +} + +struct ExecSession { // owned; outlives the exec call + process: Arc, + stdin: Option, + stdout: BoundaryOutput, // distinct from stderr for non-PTY exec + stderr: Option, + terminal: Option>, // present when a PTY was requested +} + +#[async_trait] +trait BoundaryPortForward: Send + Sync { + async fn connect(&self, target: LoopbackTarget) -> Result; +} + +#[async_trait] +trait BoundaryTerminal: Send + Sync { + async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError>; +} +``` + +`ExecSpec` carries command, arguments, environment, working directory, and PTY settings. Streams are owned, non-PTY stdout and stderr remain separate, and PTYs support resize. Port forwarding accepts only validated loopback targets. Exit status and signals are explicit and placement-neutral; a local PID is never the process handle. These operations carry the existing agent, SSH, exec, and forwarding paths behind the contract, and all of them are mandatory conformance. + +`BoundaryProcess::wait` returns one stable exit status or `Terminated` error while the backend retains process-exit observation. + +### Network mediation + +```rust +#[async_trait] +trait NetworkMediationSource: Send + Sync { + async fn accept(&self) -> Result; +} + +struct MediatedConnection { + stream: BoundaryDuplexStream, + binary_identity: Result, +} +``` + +`NetworkMediationSource` supplies outbound connections from one boundary to supervisor-owned network mediation. The backend routes all workload egress through that source and authoritatively associates each connection with the boundary without relying solely on workload-provided data. Capture, transport, placement, and coordination are backend-private. + +Every topology may use the same supervisor-owned mediation libraries or services; the source does not require a backend-specific policy engine. + +Shared implementations isolate each boundary's state and enforcement. Failure or teardown of one boundary cannot weaken another. Network-mediation unavailability never enables direct egress. + +### Binary identity + +The backend resolves executable identity for every accepted connection and delivers the result on `MediatedConnection` before network mediation evaluates policy. + +```rust +struct BinaryIdentity { + binary_path: PathBuf, // absolute executable path + binary_digest: Option, // bytes of the resolved executable object + ancestors: Vec, // nearest first + cmdline_paths: Vec, // diagnostic context; never authorizes +} +``` + +Identity describes the executable identity resolved for the accepted connection before policy evaluation. Paths are expressed in the workload's filesystem namespace. + +If binary identity cannot be resolved, the connection is denied. `ResolveError` reports that failure. A missing digest is represented as `None`. How a backend resolves identity is implementation-specific. + +Every identity field used for authorization is obtained by a trusted component from boundary or kernel state, rather than accepted as a workload claim. The result is bound to the active boundary and accepted connection; a transport tuple or workload-supplied identifier alone is not authoritative. Workload-supplied identity may be retained only as non-authorizing diagnostic context. If attribution is ambiguous or any required identity field cannot be established, the connection is denied. + +Binary identity is mandatory conformance: RFC 0002 makes it part of the outbound-policy baseline. There is no capability flag and no mode that exempts a backend from resolving identity. + +### The supervisor sequence + +The logical supervisor resolves `backend_name` and version through a trusted implementation registry. Adding a backend adds an implementation and registration, not branches in lifecycle, proxy, SSH, or session code. Delegated transport and coordination remain backend-private. + +The supervisor runs the same sequence for every backend: + +1. Obtain the `TopologyDescriptor` and trusted `SandboxContext`. +2. Verify the descriptor and resolve its `backend_name` and version without fallback. +3. Call `attach` to obtain `Bound`. +4. Connect the boundary's `NetworkMediationSource` to network mediation. +5. Call `confirm` to obtain `Ready`, then `start_agent` to obtain `Running`. +6. Use the returned runtime handles for agent wait, `exec`, and port forwarding while network mediation consumes outbound connections. + +This RFC supersedes RFC 0001's fixed in-sandbox supervisor placement and its assignment of topology-specific isolation controls to that process, generalizing the supervisor into a logical role. RFC 0001's authentication, sandbox-identity, outbound-connection, session, and reconnection requirements continue to apply. The component hosting the logical supervisor holds the required outbound gateway connection. A driver-hosted or shared supervisor routes gateway `exec`, SSH, and forwarding requests through the backend; the gateway does not initiate a connection to the boundary. + +### Failure semantics + +Every failure carries a machine-readable kind for supervisor status mapping: + +```rust +enum BackendErrorKind { Invalid, Denied, Unavailable, Failed, Terminated } +``` + +`Invalid` covers descriptor, version, and backend mismatches; `Denied` covers authenticated attachment rejection; `Unavailable` covers transient inability to serve an operation; `Failed` covers other backend faults; and `Terminated` reports boundary or workload termination, or an operation against an inactive boundary. An error never advances the lifecycle or authorizes an operation, and backend selection never falls back. + +A backend may retry backend-private work within one `attach` call. The supervisor calls `attach` at most once per provisioned topology. If it does not return `Bound`, the topology is reclaimed rather than reused. + +Failures resolve as follows: + +- an `attach` or `confirm` failure, or network-mediation initialization failure while `Bound`, prevents untrusted workload execution and causes the driver to reclaim the topology; +- if `start_agent` does not return `Running`, no untrusted process from that attempt remains, and the driver reclaims the topology; +- if `exec` or port-forward `connect` fails, the backend terminates any process or closes any connection created by that attempt while the boundary otherwise remains active; +- after `Running`, supervisor or enforcement loss follows invariant 6; when enforcement loss ends the agent, `BoundaryProcess::wait` fails with `BackendErrorKind::Terminated` where process-exit observation survives; +- network-mediation errors yield no authorized connection and do not by themselves end `Running`; and +- retained runtime handles and the network-mediation source reject new operations whenever the boundary ends, except `BoundaryProcess::wait` where the backend can still return its stable result. + +Whenever a boundary ends, the backend terminates remaining workload processes and releases the active-boundary binding before the compute driver reclaims or deprovisions the topology. If normal backend cleanup is unavailable, the compute driver uses the topology's trusted cleanup path to terminate the execution environment and invalidate the binding before reclaim or reuse. On normal agent exit, `BoundaryProcess::wait` returns the stable exit status. A retained `wait` result may outlive teardown. + +### Topologies + +The contract fixes the roles; a topology fixes their placement. Components may be co-located with the workload or hosted in trusted services, and one component may implement multiple roles. Every arrangement admitted to this contract preserves the same lifecycle, interfaces, and invariants. Actual containment depends on the workload's kernel relationship to the trusted components. The non-normative [topology matrix](./topology-matrix.md) catalogs representative placements. + +## Implementation plan + +This RFC defines the contract; implementation lands in three phases: + +1. **Contract.** Add the common types, descriptor handling, registry, and explicit backend selection from deployment configuration. +2. **Co-located backend.** Implement the co-located backend behind a deployment flag and route agent launch, egress interception, the network-mediation source, SSH, `exec`, and forwarding through it without changing behavior. +3. **Conformance and enablement.** Require every topology admitted to the RFC 0012 lifecycle to pass tests for the six contract invariants plus descriptor verification, lifecycle ordering, runtime operations, and failure semantics. Make the co-located backend the default after parity validation. Parity covers the agent, binary identity, SSH, `exec`, and forwarding paths; enablement also closes the in-pod egress gaps pinned in [codebase-grounding.md](./codebase-grounding.md), which parity alone would preserve. + +Existing placements remain outside this contract until their backend is implemented and admitted; they do not claim conformance. Delegated backends remain separate design and implementation work. + +## Risks + +| Risk | Mitigation | +|---|---| +| The Isolation Backend could duplicate compute-driver responsibilities or allow topology-specific behavior to leak back into the supervisor. | Keep the responsibility boundary explicit: the compute driver owns, provisions, and deprovisions the topology; the backend binds and operates the active boundary. The same component may implement both roles. | +| Contract conformance could be mistaken for equivalent isolation across topologies. | Treat conformance as behavioral, not as a security-strength rating. Document and validate each topology's actual containment and reject policy it cannot enforce. | +| Shared backend or network-mediation components concentrate privilege and failure impact. | Isolate state, connection attribution, enforcement, and control authority per boundary. Failure of one boundary must not weaken another or enable direct egress. | +| The mandatory contract may exclude otherwise useful but incomplete backends. | Keep the network-mediation source, binary identity, process control, `exec`, and port forwarding mandatory. An incomplete backend does not claim conformance or silently degrade. | +| A future topology may not fit the lifecycle or interfaces. | Keep placement and coordination backend-private. Add versioned contract surface only when a concrete implementation requires new common semantics. | +| A component restart may interrupt boundary operation. | Keep the last confirmed enforcement state in force and deny supervisor-dependent operations. | + +## Alternatives + +### Keep isolation embedded in the supervisor + +OpenShell could keep the current in-pod design and add topology-specific supervisor and compute-driver paths as new requirements arise. + +Doing nothing avoids a new interface, but retains privileged boundary construction beside the workload. Implementing each delegated topology as a one-off supervisor change moves that privilege for one placement but accretes topology-specific supervisor behavior. The proposed contract instead keeps one supervisor lifecycle while allowing the topology to change. + +### Extend the compute-driver contract + +The compute driver could own both provisioning and active-boundary operation. + +This is natural for topologies such as MXC, and the same component may implement both responsibilities. The interfaces remain distinct because they serve different callers and lifecycles: the gateway uses the compute driver to provision and deprovision resources, while the supervisor uses the Isolation Backend to operate an active boundary. Combining them would couple runtime policy, identity, network mediation, and process operations to the gateway-facing driver API. + +### Start with a remote backend service + +The contract could be expressed as a gRPC service or plugin ABI rather than an in-process Rust contract. [RFC 0001](../0001-core-architecture/README.md) chose gRPC for its gateway-facing drivers, so the question applies here. + +The callers differ. A gateway driver is a control-plane peer with its own release cycle, while the Isolation Backend is driven by the supervisor that operates the boundary, and the co-located topology needs no transport at all. Starting in-process serves that case directly and lets delegated implementations carry their own transport behind the same interface. A transport-bearing surface is not precluded: it is versioned contract surface, added when a concrete delegated backend requires it. + +### Standardize topology and capabilities + +The contract could expose common topology roles, placement fields, capability flags, and recovery behavior so the supervisor can compose backend components. + +That would make known deployments explicit, but it would also encode current topology assumptions and introduce capability-dependent supervisor paths. The proposal keeps placement and coordination in the opaque descriptor, requires one baseline contract, and uses the non-normative topology matrix to document representative arrangements. + +## Prior art + +- **Driver-backed subsystems (CRI/CNI/CSI).** Kubernetes factors runtime, networking, and storage into pluggable driver contracts so the orchestrator drives one interface while implementations vary. RFC 0001 describes OpenShell's other subsystems the same way; this RFC specifies the one it left open: isolation. +- **Istio privilege placement.** Init-sidecar and node-agent modes demonstrate that network setup can move without changing the policy data path. OpenShell keeps its identity-aware proxy. +- **CRI exec/attach/port-forward.** `exec` and `connect` follow CRI's `Exec` and `PortForward` shape; lifecycle and network mediation remain OpenShell-specific. + +## Open questions + +None. + +## Appendix: codebase grounding + +The claims this RFC makes about the current system, and the current-system +context behind its design, are verified with file:line references in the +supporting file [codebase-grounding.md](./codebase-grounding.md) +(against upstream commit `905b554c`, after proxy egress pipeline consolidation). diff --git a/rfc/0012-isolation-backend/codebase-grounding.md b/rfc/0012-isolation-backend/codebase-grounding.md new file mode 100644 index 0000000000..4767dc6d58 --- /dev/null +++ b/rfc/0012-isolation-backend/codebase-grounding.md @@ -0,0 +1,26 @@ +# Codebase grounding (supporting material for RFC 0012) + +This non-normative file grounds RFC 0012's claims about the current system. + +References are pinned to `905b554c` (proxy egress pipeline consolidation, #2373). Permalinks use +`https://github.com/NVIDIA/OpenShell/blob/905b554c/#L`; the `rg` +patterns locate the same code on newer revisions. + +| Claim | Reference | +|---|---| +| Combined-topology agent container's seven capabilities | `crates/openshell-driver-kubernetes/src/driver.rs:2538` (base `SYS_ADMIN`/`NET_ADMIN`/`SYS_PTRACE`/`SYSLOG`), `:2544` (`SETUID`/`SETGID`/`DAC_READ_SEARCH` under userns). `rg -n -e SYS_ADMIN -e NET_ADMIN -e SYS_PTRACE -e SYSLOG -e SETUID -e SETGID -e DAC_READ_SEARCH crates/openshell-driver-kubernetes/src/driver.rs` | +| Spec already separated from the netns handle | `crates/openshell-supervisor-process/src/process.rs:527`/`:535` (`ProcessHandle::spawn` takes `netns: Option<&NetworkNamespace>`) | +| Six `setns(CLONE_NEWNET)` call sites the contract's runtime interfaces replace (agent launch, SSH exec and forward, supervisor sessions, and namespace construction or entry); plus `CLONE_NEWNS` at `:449` (`unshare`, private mount namespace) and `:480` (`setns`, enter mount namespace, added for sidecar topology) | `process.rs:695`, `ssh.rs:653`/`:1262`, `supervisor_session.rs:735`, `netns/mod.rs:226`/`:342` (`rg -n "CLONE_NEWNET" crates/openshell-supervisor-process`) | +| `nft`-absent fail-open (the invariant bug), in-pod path only; the sidecar path uses `nft` with an `iptables-legacy` fallback and returns an error if neither establishes enforcement | `crates/openshell-supervisor-process/src/netns/mod.rs:265`; logs and returns `Ok(())` at `:277`; sidecar fallback at `:459`-`:471` | +| In-pod nftables ceiling is accept-by-default and rejects only TCP and UDP, so reading it back does not prove "only the proxy can egress" | `crates/openshell-supervisor-process/src/netns/nft_ruleset.rs:53` (`type filter hook output priority 0; policy accept`), `:56`-`:92` (proxy/loopback/established accept, then `reject` for IPv4 and IPv6 TCP and UDP only at `:106`+; other protocols and raw sockets pass once the host forwards the subnet). `rg -n "policy accept" crates/openshell-supervisor-process/src/netns` | +| Compute driver owns the execution domain (cgroup/resources, security context, device allocation set on the pod by the driver, not the supervisor) | `crates/openshell-driver-kubernetes/src/driver.rs` builds the pod/container spec; `rg -n -e securityContext -e resources -e 'cdi\.k8s\.io' -e devices crates/openshell-driver-kubernetes/src` | +| VM driver enables forwarding/MASQUERADE (host-forward assumption is load-bearing) | `crates/openshell-driver-vm/src/runtime.rs:418`/`:437` | +| No `StartSandbox` RPC (create and start fused; no driver start gate) | `proto/compute_driver.proto` has `CreateSandbox`/`StopSandbox`/`DeleteSandbox` only | +| Gateway already speaks exec/session/port-forward; no lifecycle `Attach` (`AttachSandboxProvider` exists but attaches a provider record to a running sandbox, not the isolation lifecycle) | `proto/openshell.proto` (`ExecSandbox`, `ExecSandboxInteractive`, `CreateSshSession`, `ForwardTcp`, `AttachSandboxProvider`) | +| Agent command via CLI/`SANDBOX_COMMAND`; no admission-bound spec field today; the `sleep infinity` placeholder resolves `sleep` from the agent image's own filesystem | `crates/openshell-sandbox/src/main.rs:601`; K8s driver sets `sleep infinity` via `SANDBOX_COMMAND` at `driver.rs:2937` (`rg -n "sleep infinity" crates/openshell-driver-kubernetes/src`) | +| Init containers: `copy-self` (trusted, the OpenShell binary) and `workspace-init` (runs as root from the agent's own image, so its executables are image-provided); sidecar topology adds `openshell-network-init` (nftables setup, `NET_ADMIN`/`NET_RAW`/`CHOWN`/`FOWNER`) and `openshell-supervisor-network` runtime sidecar | `driver.rs:423` (`WORKSPACE_INIT_CONTAINER_NAME`), `:1506` (`copy-self` invocation), `:2113` (workspace-init container), `:1423` (`SUPERVISOR_NETWORK_INIT_CONTAINER_NAME`), `:1426` (`SUPERVISOR_NETWORK_SIDECAR_NAME`); `rg -n -e restart_policy -e workspace-init -e openshell-network-init -e openshell-supervisor-network crates/openshell-driver-kubernetes/src` | +| Network policy is OPA per-CONNECT, not the boundary; identity via procfs | `crates/openshell-supervisor-network/src/opa.rs` (`NetworkInput`: `binary_path`/`binary_sha256`/`ancestors`/`cmdline_paths`), `procfs.rs`, glued in `proxy.rs:1955` (`authorize_egress_intent`; `NetworkInput` built at `:2032`) | +| Network enforcement already shares one implementation across placements: the combined path constructs and retains networking before agent launch, while the network-only sidecar owns the proxy, policy polling, and a topology-private control channel | `crates/openshell-sandbox/src/lib.rs:355` (`networking`), `:389` (`sidecar_control_server`), `:590` (`run_policy_poll_loop`), `:686` (`run_process`, after networking setup), `:736` (network-only sidecar lifecycle). `rg -n -e 'let networking' -e sidecar_control_server -e run_policy_poll_loop -e 'Network-only sidecar mode' crates/openshell-sandbox/src/lib.rs` | +| Binary identity is resolved after the connection is accepted (`/proc/net/tcp` inode lookup, socket-owner search, `/proc//exe`, then PPID walking), so executable identity and ancestry describe state observed through trusted kernel interfaces during policy evaluation rather than an atomic snapshot at `connect()` | `crates/openshell-supervisor-network/src/procfs.rs:165` (`resolve_tcp_peer_binary`), `:343` (`parse_proc_net_tcp`), `:441` (`find_socket_inode_owners`), `:227` (`read_ppid`), `:243` (`collect_ancestor_binaries`). `rg -n -e resolve_tcp_peer_binary -e collect_ancestor_binaries crates/openshell-supervisor-network/src` | +| Identity display paths come from reading `/proc//exe`, and the digest covers that live executable object at resolution time rather than a re-read of the display path | `crates/openshell-supervisor-network/src/procfs.rs:127` (`binary_path`), `:134`-`:135` (`read_link` of `/proc//exe`), `proxy.rs:1800`/`:1816` (binary and ancestors both via `verify_or_cache_process_exe`), `identity.rs:107` (hashes `/proc//exe`); digest rationale at `procfs.rs:117`-`:122`. `rg -n -e 'fn binary_path' -e verify_or_cache_process_exe crates/openshell-supervisor-network/src` | +| Static privilege ceiling on every spawned process; OPA never evaluates exec | `process.rs:527` (`ProcessHandle::spawn`), `:710`/`:812` (`drop_privileges` call sites), `:721`/`:818`-`:819` (sandbox enforcement); SSH reaches the same `enter_netns_and_sandbox` path (`ssh.rs:1245`) | diff --git a/rfc/0012-isolation-backend/topology-matrix.md b/rfc/0012-isolation-backend/topology-matrix.md new file mode 100644 index 0000000000..8dc14d84ba --- /dev/null +++ b/rfc/0012-isolation-backend/topology-matrix.md @@ -0,0 +1,36 @@ +# Topology matrix + +This non-normative matrix compares representative mappings of RFC 0012's logical +roles. It records role placement, sharing, and relationship to the workload +kernel; it does not select a deployment or establish conformance. + +## Representative placements + +| Pattern | Logical supervisor and network-mediation placement | Backend placement | Workload-kernel relationship | Topology status | +|---|---|---|---|---| +| **Co-located/in-pod** | With the workload | In the supervisor process | Trusted components share the workload's host, guest, or application kernel, depending on the runtime | Placement implemented (original topology) | +| **Same-pod composite** | Spans the workload-local supervisor process and, when used, a network-mediation sidecar | In the workload-local supervisor process | Components share the workload's kernel | Placement implemented (#2076) | +| **Delegated backend components** | With the workload and any delegated mediation component | A node or remote helper establishes some controls behind a workload-local backend | Depends on which trusted components remain with the workload | Placement proposed (#2606) | +| **Driver-hosted/shared service** | With the compute driver or another trusted service; no in-sandbox supervisor process is required | May be co-located with the logical supervisor; one host may operate many isolated boundaries | Depends on the workload runtime | Placement proposed | + +## Durable rules + +- Every active boundary has one verified descriptor, one trusted + `SandboxContext`, and at most one logical supervisor, which may span multiple + coupled processes. +- Physical processes and listeners may be shared, but lifecycle state, policy, + binary identity, enforcement, and cleanup remain isolated per boundary. +- Moving a privileged component does not itself provide kernel separation. + +## Kernel relationships + +| Relationship | Meaning | +|---|---| +| **Shared host kernel** | The workload and the trusted components relied on for containment run on the host's kernel. | +| **Shared guest or application kernel** | The workload and those trusted components share one isolated kernel: a VM guest kernel or a userspace application kernel. | +| **Kernel-separated** | The trusted components relied on for containment run outside the workload's kernel. | + +## Status + +This matrix is non-normative. It illustrates implementations of RFC 0012; it +does not extend the contract.