feat(isolation): add RFC 0012 backend contract

Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com>
This commit is contained in:
Drew Newberry
2026-09-11 15:12:15 -07:00
committed by Drew Newberry
parent 5b57f0d154
commit 27fe09e04b
7 changed files with 1462 additions and 0 deletions
Generated
+9
View File
@@ -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"
+30
View File
@@ -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__"],
)
+22
View File
@@ -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
+609
View File
@@ -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<Self>`), 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<dyn _>` / `Arc<dyn _>`.
//!
//! `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<u8>,
}
/// 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<String, Arc<dyn IsolationBackend>>,
}
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<dyn IsolationBackend>) -> 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<dyn IsolationBackend>, 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<Box<dyn BoundBoundary>, 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<dyn NetworkMediationSource>;
/// Confirm standing enforcement. How a backend establishes confidence is
/// private to that backend; confirmation fails closed.
async fn confirm(self: Box<Self>) -> Result<Box<dyn ReadyBoundary>, 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<Self>) -> Result<Box<dyn RunningBoundary>, 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<dyn BoundaryProcess>;
/// The in-boundary exec interface.
fn exec(&self) -> Arc<dyn BoundaryExec>;
/// The loopback port-forward interface.
fn port_forward(&self) -> Arc<dyn BoundaryPortForward>;
}
// ============================================================================
// 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<BoundaryExitStatus, BackendError>;
/// 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<dyn AsyncWrite + Send + Unpin>;
/// A boxed async reader from a boundary process's stdout or stderr.
pub type BoundaryOutput = Box<dyn AsyncRead + Send + Unpin>;
/// 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<dyn BoundaryProcess>,
/// Stdin writer, if not a PTY-merged stream.
pub stdin: Option<BoundaryInput>,
/// Stdout reader.
pub stdout: BoundaryOutput,
/// Stderr reader, distinct from stdout for non-PTY exec.
pub stderr: Option<BoundaryOutput>,
/// PTY control, present when a terminal was requested.
pub terminal: Option<Arc<dyn BoundaryTerminal>>,
}
/// 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<String>,
/// Extra environment over the boundary's base.
pub env: Vec<(String, String)>,
/// Working directory, if any.
pub workdir: Option<String>,
/// 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<ExecSession, BackendError>;
}
// ============================================================================
// 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<Self, BackendError> {
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<T: AsyncRead + AsyncWrite + Send + Unpin> DuplexStream for T {}
/// An open connection into a boundary loopback target.
pub type BoundaryDuplexStream = Box<dyn DuplexStream>;
/// 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<BoundaryDuplexStream, BackendError>;
}
// ============================================================================
// 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<Sha256Digest>,
/// Ancestor process binaries, nearest first.
pub ancestors: Vec<PathBuf>,
/// Absolute script/interpreter paths drawn from the process cmdlines.
/// Diagnostic context; never authorizes.
pub cmdline_paths: Vec<PathBuf>,
}
/// 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<Self, Self::Err> {
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<BinaryIdentity, ResolveError>,
}
/// 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<MediatedConnection, BackendError>;
}
#[cfg(test)]
mod tests;
@@ -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<Vec<BoundarySignal>>,
}
impl MockProcess {
fn new() -> Arc<Self> {
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<BoundaryExitStatus, BackendError> {
// 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<K>(PhantomData<K>);
#[async_trait]
impl<K: MockKind> NetworkMediationSource for MockSource<K> {
async fn accept(&self) -> Result<MediatedConnection, BackendError> {
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<MediatedConnection, BackendError> {
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<Option<(u16, u16)>>,
}
#[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<ExecSession, BackendError> {
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<dyn BoundaryTerminal> = 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<BoundaryDuplexStream, BackendError> {
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<K> {
source: Arc<MockSource<K>>,
}
struct MockReady<K> {
_k: PhantomData<K>,
}
struct MockRunning<K> {
process: Arc<MockProcess>,
exec: Arc<MockExec>,
port_forward: Arc<MockPortForward>,
_k: PhantomData<K>,
}
#[async_trait]
impl<K: MockKind> BoundBoundary for MockBound<K> {
fn network_mediation_source(&self) -> Arc<dyn NetworkMediationSource> {
self.source.clone()
}
async fn confirm(self: Box<Self>) -> Result<Box<dyn ReadyBoundary>, BackendError> {
Ok(Box::new(MockReady::<K> { _k: PhantomData }))
}
}
#[async_trait]
impl<K: MockKind> ReadyBoundary for MockReady<K> {
async fn start_agent(self: Box<Self>) -> Result<Box<dyn RunningBoundary>, BackendError> {
Ok(Box::new(MockRunning::<K> {
process: MockProcess::new(),
exec: Arc::new(MockExec),
port_forward: Arc::new(MockPortForward),
_k: PhantomData,
}))
}
}
impl<K: MockKind> RunningBoundary for MockRunning<K> {
fn agent(&self) -> Arc<dyn BoundaryProcess> {
self.process.clone()
}
fn exec(&self) -> Arc<dyn BoundaryExec> {
self.exec.clone()
}
fn port_forward(&self) -> Arc<dyn BoundaryPortForward> {
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<K> {
attached: AtomicBool,
_k: PhantomData<K>,
}
impl<K> MockBackend<K> {
fn new() -> Self {
Self {
attached: AtomicBool::new(false),
_k: PhantomData,
}
}
}
#[async_trait]
impl<K: MockKind> IsolationBackend for MockBackend<K> {
fn backend_name(&self) -> &'static str {
K::BACKEND_ID
}
fn version(&self) -> u32 {
INTERFACE_VERSION
}
async fn attach(
&self,
descriptor: VerifiedTopologyDescriptor,
sandbox: SandboxContext,
) -> Result<Box<dyn BoundBoundary>, 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::<K> {
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<Box<dyn BoundBoundary>, BackendError> {
unreachable!("must never be resolved")
}
}
// ---------------------------------------------------------------------------
// Helpers.
// ---------------------------------------------------------------------------
fn registry() -> BackendRegistry {
let mut reg = BackendRegistry::new();
reg.register(Arc::new(MockBackend::<Primary>::new()))
.expect("register primary");
reg.register(Arc::new(MockBackend::<Secondary>::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<Box<dyn RunningBoundary>, 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::<Primary>::new()))
.expect("first");
let err = reg
.register(Arc::new(MockBackend::<Primary>::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(&reg, descriptor("mock-primary"), "mock-primary")
.await
.expect("primary lifecycle");
let secondary = drive(&reg, 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(&reg, descriptor("mock-primary"), "mock-primary")
.await
.expect("primary lifecycle");
let secondary = drive(&reg, 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(&reg, 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(&reg, 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(&reg, 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(&reg, 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::<Secondary>(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
);
}
+50
View File
@@ -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<String>,
/// Working directory for the entrypoint, if any.
pub workdir: Option<String>,
/// 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;
@@ -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/<path>#L<line>`; 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/<pid>/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/<pid>/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/<pid>/exe`), `proxy.rs:1800`/`:1816` (binary and ancestors both via `verify_or_cache_process_exe`), `identity.rs:107` (hashes `/proc/<pid>/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`) |