From 27fe09e04bc3e45c63aea93ceb8cbf91508ee1a7 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> --- 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 ++ .../codebase-grounding.md | 26 + 7 files changed, 1462 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/codebase-grounding.md diff --git a/Cargo.lock b/Cargo.lock index c633b7f26..80df560f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4347,6 +4347,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 000000000..8bc2b233e --- /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 000000000..be703c52c --- /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 000000000..f94212ca9 --- /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 000000000..645bb7ede --- /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 000000000..d7f4e3218 --- /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/codebase-grounding.md b/rfc/0012-isolation-backend/codebase-grounding.md new file mode 100644 index 000000000..4767dc6d5 --- /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`) |