mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-30 07:33:59 +08:00
feat(isolation): add RFC 0012 backend contract
Signed-off-by: Drew Newberry <385+drew@users.noreply.github.com>
This commit is contained in:
Generated
+9
@@ -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"
|
||||
|
||||
@@ -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__"],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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(®, 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::<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
|
||||
);
|
||||
}
|
||||
@@ -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`) |
|
||||
Reference in New Issue
Block a user