Files
Drew Newberry 4688061882 fix(sandbox): deliver complete exec output before success (#3688)
* fix(sandbox): preserve exec output through channel close

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(exec): propagate output delivery failures before exit

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(cli): clarify exec output delivery failures

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
2026-09-25 04:03:49 +00:00

1076 lines
42 KiB
Rust

// 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 backend descriptor + sandbox context -> Bound -> confirm -> Ready
//! -> start_agent -> Running
//! ```
//!
//! Each transition consumes the prior state by value (`self: Box<Self>`).
//! Trusted backend implementations construct confirmation through a validating
//! constructor; the supervisor cannot obtain a ready boundary without confirmed
//! backend-neutral enforcement properties.
//! 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 establishes and binds
//! the boundary, returns `Bound`, or fails closed. It never binds a resource
//! already bound to an active boundary. Binary identity travels on every
//! [`PendingTcpOpen`], resolved for that exact socket and process
//! generation; an unresolved identity denies the open.
//!
//! The contract is transport-neutral. Compute drivers keep runtime placement
//! and coordination details behind these interfaces.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::sync::oneshot;
pub use openshell_core::SandboxSessionId;
pub use openshell_core::policy::SandboxPolicy;
// ============================================================================
// 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),
/// The selected backend does not implement an optional contract operation.
Unsupported(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 or backend mismatch.
Invalid,
/// Authenticated attachment rejection.
Denied,
/// Transient inability to serve an operation.
Unavailable,
/// The selected backend does not implement the requested optional operation.
Unsupported,
/// 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::Unsupported(_) => BackendErrorKind::Unsupported,
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::Unsupported(m) => write!(f, "operation unsupported: {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 isolation backend descriptor envelope.
///
/// The compute driver supplies one for the selected isolation backend. The opaque
/// payload identifies an existing resource or carries the trusted prepared
/// inputs the backend needs to establish one during `attach`; its protection
/// and resource lifecycle remain owned by the compute driver.
#[derive(Debug, Clone)]
pub struct BackendDescriptor {
/// 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 VerifiedBackendDescriptor {
descriptor: BackendDescriptor,
}
impl VerifiedBackendDescriptor {
/// 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
}
}
/// Exact non-root identity selected before the immutable workload is created.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedWorkloadIdentity {
/// Effective and real user ID used by sandbox and all workload children.
pub uid: u32,
/// Primary group ID used by sandbox and all workload children.
pub gid: u32,
/// Sorted, unique supplementary groups inherited unchanged by children.
pub supplementary_gids: Vec<u32>,
/// Driver-defined resolution source (`policy`, `template`, or `image`).
pub source: String,
/// Digest of the immutable image/rootfs/config used for resolution.
pub resource_digest: String,
}
impl ResolvedWorkloadIdentity {
/// Validate and construct a final workload identity.
pub fn new(
uid: u32,
gid: u32,
mut supplementary_gids: Vec<u32>,
source: String,
resource_digest: String,
) -> Result<Self, BackendError> {
if uid == 0 || gid == 0 || supplementary_gids.contains(&0) {
return Err(BackendError::Descriptor(
"workload identity must not contain UID or GID zero".to_string(),
));
}
if source.trim().is_empty() || resource_digest.trim().is_empty() {
return Err(BackendError::Descriptor(
"workload identity source and resource digest are required".to_string(),
));
}
supplementary_gids.retain(|supplementary_gid| *supplementary_gid != gid);
supplementary_gids.sort_unstable();
supplementary_gids.dedup();
Ok(Self {
uid,
gid,
supplementary_gids,
source,
resource_digest,
})
}
}
/// The trusted sandbox context, constructed by trusted common code after the
/// control plane assigns the resource to the admitted sandbox.
///
/// Carries the admitted launch-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,
/// Which create or start-from-stopped launch this attachment belongs to.
///
/// Retries of one durable launch reuse this identity. A later launch gets
/// a new identity even when the compute platform reuses its outer resource.
pub session_id: SandboxSessionId,
/// The admitted launch-time policy.
pub policy: SandboxPolicy,
/// The admitted agent workload.
pub agent: AgentSpec,
/// Immutable identity already applied by the driver to sandbox and agent.
pub identity: ResolvedWorkloadIdentity,
}
/// The agent workload to run inside the boundary.
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.
///
/// # Errors
///
/// Returns [`BackendError::Descriptor`] for a duplicate `backend_name`.
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:?}"
)));
}
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 `backend_name` must equal the admitted name;
/// - a backend must be registered under that name.
///
/// # Errors
///
/// Returns [`BackendError::Descriptor`] for an admission mismatch, and
/// [`BackendError::NotRegistered`] when no backend is
/// registered for the admitted name.
pub fn resolve(
&self,
descriptor: BackendDescriptor,
admitted_backend_name: &str,
) -> Result<(Arc<dyn IsolationBackend>, VerifiedBackendDescriptor), BackendError> {
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
)));
}
Ok((backend, VerifiedBackendDescriptor { 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;
/// Validate the opaque payload, establish any boundary-local resources,
/// and atomically bind them to the trusted sandbox context: returns `Bound`
/// or fails closed. Never binds a resource already bound to an active
/// boundary. The authenticated runtime session must match
/// `sandbox.session_id`; a session from an earlier launch is rejected.
/// Durable resource lifecycle remains owned by the compute driver or
/// external orchestrator that supplied the descriptor.
async fn attach(
&self,
descriptor: VerifiedBackendDescriptor,
sandbox: SandboxContext,
) -> Result<Box<dyn BoundBoundary>, BackendError>;
}
// ============================================================================
// Lifecycle states
// ============================================================================
/// Bound: the backend 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 network
/// requests. TCP and DNS remain typed operations so consumers cannot mix
/// their framing, decisions, or response semantics.
/// Retained by the supervisor before consuming `Bound`.
fn network_mediation_source(&self) -> Arc<dyn NetworkMediationSource>;
/// Trusted host-side dial target for the well-known host-gateway aliases.
///
/// Backends return this when the mediation service runs outside the
/// workload boundary and therefore cannot use the boundary's resolver
/// view. The supervisor preserves the original hostname for policy, HTTP,
/// and TLS while dialing this backend-provided address. Returning `None`
/// leaves host-gateway discovery to the supervisor's local environment.
fn host_gateway_ip(&self) -> Option<IpAddr> {
None
}
/// Confirm standing enforcement and return measured sandbox evidence.
/// Confirmation fails closed and does not execute untrusted workload code.
async fn confirm(self: Box<Self>) -> Result<ConfirmedBoundary, BackendError>;
}
/// Backend-neutral guarantees established by the component that owns the outer
/// network fence.
///
/// The enforcement owner may be a compute driver or a delegated isolation
/// backend. It owns its native evidence schema and the code that validates it.
/// After validation, it projects that evidence into these guarantees and
/// supplies a digest that binds the original evidence to this generation. The
/// common runtime only validates and compares this projection; it never
/// interprets backend- or runtime-specific fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OuterFenceGuarantee {
/// No workload packet can leave without an explicit mediated decision.
DefaultDenyEgress,
/// The enforcement owner found no network path outside the mediated boundary.
NoUnmanagedEgressPath,
/// Previously granted access can be revoked by the enforcement owner.
RevocationVerified,
/// Loss of the fence's controller does not open network access.
ControllerLossFailsClosed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OuterFenceGuarantees {
/// Sandbox generation for which the evidence was collected.
pub generation: String,
/// Complete set of normalized guarantees established by the enforcement owner.
pub established: BTreeSet<OuterFenceGuarantee>,
/// Commitment to the enforcement owner's native evidence.
pub evidence_digest: Sha256Digest,
}
impl OuterFenceGuarantees {
/// Bind guarantees explicitly established by validated enforcement evidence.
///
/// This constructor deliberately does not infer guarantees from the mere
/// presence of evidence. The enforcement owner must inspect its native
/// state and project each established guarantee before calling this
/// function.
pub fn from_enforcement_evidence(
generation: impl Into<String>,
established: impl IntoIterator<Item = OuterFenceGuarantee>,
native_evidence: &[u8],
) -> Result<Self, BackendError> {
let generation = generation.into();
if generation.is_empty() || native_evidence.is_empty() {
return Err(BackendError::Descriptor(
"outer fence generation and native evidence are required".to_string(),
));
}
let mut binding = Vec::with_capacity(8 + generation.len() + native_evidence.len());
binding.extend_from_slice(&(generation.len() as u64).to_be_bytes());
binding.extend_from_slice(generation.as_bytes());
binding.extend_from_slice(native_evidence);
Ok(Self {
generation,
established: established.into_iter().collect(),
evidence_digest: Sha256Digest::compute(&binding),
})
}
/// Validate the common guarantees against the admitted generation.
pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> {
let required = BTreeSet::from([
OuterFenceGuarantee::DefaultDenyEgress,
OuterFenceGuarantee::NoUnmanagedEgressPath,
OuterFenceGuarantee::RevocationVerified,
OuterFenceGuarantee::ControllerLossFailsClosed,
]);
let complete = !self.generation.is_empty()
&& self.generation == expected_generation
&& self.established == required;
if complete {
Ok(())
} else {
Err(BackendError::Confirm(
"outer fence guarantees are incomplete or bound to another generation".to_string(),
))
}
}
}
/// A backend-neutral security property established before agent launch.
///
/// `mechanism` is diagnostic and audit metadata. It never authorizes launch;
/// the registered backend is responsible for validating its mechanism-specific
/// evidence before setting `enforced`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnforcedProperty {
pub enforced: bool,
pub mechanism: String,
}
impl EnforcedProperty {
#[must_use]
pub fn new(enforced: bool, mechanism: impl Into<String>) -> Self {
Self {
enforced,
mechanism: mechanism.into(),
}
}
fn validate(&self, name: &str) -> Result<(), BackendError> {
if self.enforced && !self.mechanism.trim().is_empty() {
Ok(())
} else {
Err(BackendError::Confirm(format!(
"{name} is not enforced or has no declared mechanism"
)))
}
}
}
/// Security properties every isolation backend establishes before launch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoundaryProperties {
pub filesystem_confinement: EnforcedProperty,
pub egress_interception: EnforcedProperty,
pub request_attribution: EnforcedProperty,
pub privilege_floor: EnforcedProperty,
}
impl BoundaryProperties {
fn validate(&self) -> Result<(), BackendError> {
self.filesystem_confinement
.validate("filesystem confinement")?;
self.egress_interception.validate("egress interception")?;
self.request_attribution.validate("request attribution")?;
self.privilege_floor.validate("privilege floor")
}
}
/// Per-boundary confirmation produced before agent launch.
///
/// Common validation binds the confirmation to the admitted workload and
/// checks backend-neutral properties. `backend_audit` remains opaque to this
/// crate; the registered backend owns its schema and validates it before
/// constructing [`ConfirmedBoundary`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoundaryConfirmation {
pub generation: String,
pub identity: ResolvedWorkloadIdentity,
pub properties: BoundaryProperties,
pub authenticated_supervisor: bool,
pub session_id: SandboxSessionId,
pub outer_fence: OuterFenceGuarantees,
/// The backend-owned containment primitive terminates the workload when its
/// Sandbox Runtime exits.
pub runtime_exit_terminates_workload: bool,
pub resource_claims: BTreeMap<String, String>,
pub backend_audit: serde_json::Value,
}
impl BoundaryConfirmation {
/// Validate common security properties and immutable launch binding.
pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> {
self.outer_fence.validate(&self.generation)?;
self.properties.validate()?;
let complete = &self.identity == expected
&& self.authenticated_supervisor
&& self.runtime_exit_terminates_workload
&& !self.generation.is_empty();
if complete {
Ok(())
} else {
Err(BackendError::Confirm(
"boundary confirmation is incomplete or mismatched".to_string(),
))
}
}
}
/// Ready boundary paired with the confirmation established by `confirm`.
pub struct ConfirmedBoundary {
boundary: Box<dyn ReadyBoundary>,
confirmation: BoundaryConfirmation,
}
impl ConfirmedBoundary {
/// Construct confirmation after checking backend-neutral properties and
/// immutable identity binding.
///
/// Backend implementations are trusted to validate their audit evidence and
/// bind this confirmation to their resource. This constructor enforces the
/// common requirements without requiring those implementations to live in
/// the interface crate.
///
/// # Errors
///
/// Returns an error if confirmation is incomplete or the identity does not match.
pub fn try_new(
boundary: Box<dyn ReadyBoundary>,
confirmation: BoundaryConfirmation,
expected: &ResolvedWorkloadIdentity,
) -> Result<Self, BackendError> {
confirmation.validate(expected)?;
Ok(Self {
boundary,
confirmation,
})
}
/// Return the record carried by this confirmed state.
pub fn confirmation(&self) -> &BoundaryConfirmation {
&self.confirmation
}
/// Consume confirmation and advance to the sole launch-capable state.
pub fn into_boundary(self) -> Box<dyn ReadyBoundary> {
self.boundary
}
}
/// 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.
#[async_trait]
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 connection interface used by port forwarding and service exposure.
fn loopback_connector(&self) -> Arc<dyn BoundaryLoopbackConnector>;
/// Permanently terminate the boundary's owned process tree and return only
/// after the backend has acknowledged terminal state. A driver may use
/// destruction of the outer runtime as fallback proof when this operation
/// cannot complete.
async fn terminate(&self) -> Result<(), BackendError>;
}
// ============================================================================
// 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 {
/// Attach to the admitted process's retained standard I/O. The boundary
/// remains the process owner and may permit only one control attachment.
async fn attach(&self) -> Result<ProcessAttachment, BackendError> {
Err(BackendError::Unsupported(
"process attachment is not supported".to_string(),
))
}
/// 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 control-side attachment to the admitted process's retained I/O.
pub struct ProcessAttachment {
/// Stdin writer.
pub stdin: BoundaryInput,
/// Stdout reader, or the PTY-merged output stream.
pub stdout: BoundaryOutput,
/// Stderr reader, distinct from stdout for non-PTY processes.
pub stderr: Option<BoundaryOutput>,
/// PTY control, present when the admitted process owns a terminal.
pub terminal: Option<Arc<dyn BoundaryTerminal>>,
}
/// 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>>,
/// Final status of the attached output stream, including delivery failure.
/// The process handle's stable wait status remains independent of attachment.
pub output_status: Option<oneshot::Receiver<BoundaryExitStatus>>,
}
/// 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>,
/// Workload-local shell request. When present, the boundary resolves the
/// concrete shell and ignores `program` and `args`.
pub shell: Option<ShellSpec>,
/// Trusted helper implemented by the sandbox runtime. This intent is set
/// only by supervisor-owned protocol adapters, never by public exec APIs.
pub runtime_helper: Option<RuntimeHelper>,
/// 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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeHelper {
Sftp,
}
/// A shell invocation whose executable must be resolved inside the workload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShellSpec {
/// Command passed to the shell. `None` starts an interactive shell when a
/// PTY is requested and a plain shell otherwise.
pub command: Option<String>,
/// Whether command execution should load the shell's login environment.
pub login: 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>;
/// Install the current provider environment for future process launches.
///
/// Success requires an authenticated acknowledgment from the running
/// boundary. Implementations serialize this operation with exec so an
/// older publication cannot replace the acknowledged environment.
async fn synchronize_provider_environment(
&self,
) -> Result<ProviderEnvironmentInstallation, BackendError> {
Err(BackendError::Unsupported(
"provider environment installation acknowledgment is unavailable".to_string(),
))
}
}
/// Evidence that the running workload boundary installed one provider snapshot.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProviderEnvironmentInstallation {
/// Local supervisor snapshot that produced the acknowledged environment.
pub installation_id: String,
/// Opaque provider content fingerprint.
pub revision: u64,
/// Authenticated and confirmed workload boundary session.
pub session_id: SandboxSessionId,
}
// ============================================================================
// 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>;
/// Protected connector to services listening inside the boundary.
///
/// Higher layers use this primitive for both end-user port forwarding and
/// service exposure. Authentication, public listeners, routing, and exposure
/// lifecycle remain outside the isolation backend.
#[async_trait]
pub trait BoundaryLoopbackConnector: Send + Sync {
/// Connect to `target` inside the boundary.
async fn connect(&self, target: LoopbackTarget) -> Result<BoundaryDuplexStream, BackendError>;
}
// ============================================================================
// Mediation and binary identity
// ============================================================================
/// Path and digest evidence for one executable in a process identity chain.
///
/// Every instance is authorization-capable, whether it describes the process
/// that owns a connection or one of its executable ancestors. A missing digest
/// is `None`, never an empty value; binary-scoped policy cannot authorize an
/// executable whose digest is unavailable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutableIdentity {
/// Absolute executable path in the workload filesystem namespace.
pub path: PathBuf,
/// Digest of the resolved executable object. `None` when unavailable.
pub digest: Option<Sha256Digest>,
}
/// Executable identity for one accepted connection, resolved by the backend and
/// delivered on [`PendingTcpOpen`] before the mediation service evaluates
/// policy.
///
/// How a backend resolves identity is private to that backend; the shape and
/// fail-closed semantics do not change.
#[derive(Debug, Clone)]
pub struct BinaryIdentity {
/// Executable that owns the accepted connection.
pub executable: ExecutableIdentity,
/// Ancestor process executables, nearest first.
pub ancestors: Vec<ExecutableIdentity>,
/// 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, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Sha256Digest([u8; 32]);
impl TryFrom<String> for Sha256Digest {
type Error = ResolveError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
impl From<Sha256Digest> for String {
fn from(value: Sha256Digest) -> Self {
value.to_string()
}
}
impl Sha256Digest {
fn compute(bytes: &[u8]) -> Self {
use sha2::{Digest as _, Sha256};
Self(Sha256::digest(bytes).into())
}
/// 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.bytes().all(|byte| byte.is_ascii_hexdigit()) {
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))
}
}
/// Immutable socket metadata supplied with a pending external TCP open.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkSocketMetadata {
/// Kernel socket cookie captured for the exact open-file description.
pub socket_cookie: u64,
/// Whether the workload requested nonblocking operation.
pub nonblocking: bool,
/// Workload process generation that owns the open.
pub process_generation: u64,
}
/// Typed supervisor decision for one pending TCP open.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TcpOpenDecision {
/// L4 authorization and a bounded relay handler are ready. L7 policy still
/// applies to bytes after the local connection commits.
RelayReady,
/// The socket remains unchanged and connect returns this positive errno.
Denied(TcpOpenDenial),
}
/// Placement-neutral reason why a staged TCP open was not committed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TcpOpenDenial {
/// The admitted network policy rejected the request.
PolicyDenied,
/// The backend could not resolve authoritative executable identity.
IdentityUnavailable,
/// The requested destination could not be validated.
InvalidDestination,
/// A bounded mediation resource was exhausted.
ResourceExhausted,
/// The mediation path became unavailable before commit.
MediationUnavailable,
}
/// Timing captured while one mediated operation crosses the sandbox boundary.
///
/// The durations are measured in the sandbox's monotonic clock. The supervisor
/// timestamp is local to the supervisor and is intentionally not serialized.
#[derive(Debug, Clone)]
pub struct MediationTiming {
/// Time from receiving the sandbox syscall notification to queueing it for
/// the transport.
pub sandbox_notification_to_queue: Duration,
/// Time spent waiting in the sandbox-side mediation queue.
pub sandbox_queue_wait: Duration,
/// Time at which the supervisor received the operation.
pub supervisor_received_at: Instant,
}
impl Default for MediationTiming {
fn default() -> Self {
Self {
sandbox_notification_to_queue: Duration::ZERO,
sandbox_queue_wait: Duration::ZERO,
supervisor_received_at: Instant::now(),
}
}
}
/// A staged workload TCP open delivered before its local relay is committed.
///
/// An `Err` identity must be denied and audited. The supervisor owns
/// `result`; dropping it cancels the open without changing the workload socket.
pub struct PendingTcpOpen {
/// Staged byte stream whose workload side is committed only after
/// [`TcpOpenDecision::RelayReady`].
pub stream: BoundaryDuplexStream,
/// Executable identity, resolved by the backend for this connection.
pub binary_identity: Result<BinaryIdentity, ResolveError>,
/// Original external destination captured from the blocked syscall.
pub destination: SocketAddr,
/// Socket and process identity bound to this request.
pub socket: NetworkSocketMetadata,
/// Policy generation under which the request was created.
pub policy_generation: u64,
/// Monotonic stage timing for performance diagnostics.
pub timing: MediationTiming,
/// Single-use completion channel back to the sandbox broker.
pub decision: oneshot::Sender<TcpOpenDecision>,
}
/// 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 request with its active boundary without relying solely on a
/// transport tuple or workload-provided identifier. TCP and DNS use separate
/// accepts so they can be consumed concurrently with independent backpressure.
/// An `Err` means that mediation lane is unusable and fails closed.
#[async_trait]
pub trait NetworkMediationSource: Send + Sync {
/// Await the next staged workload TCP open.
async fn accept_tcp(&self) -> Result<PendingTcpOpen, BackendError>;
/// Await the next workload DNS query.
async fn accept_dns(&self) -> Result<PendingDnsQuery, BackendError>;
}
/// DNS transport used by one workload exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DnsTransport {
/// One DNS wire datagram without a TCP length prefix.
Udp,
/// One DNS message received over a TCP resolver connection.
Tcp,
}
/// One workload DNS request and its fail-closed response channel.
pub struct PendingDnsQuery {
/// Exactly one DNS wire message, without a DNS-over-TCP length prefix.
/// The backend removes and restores transport framing.
pub message: Vec<u8>,
/// Workload DNS transport.
pub transport: DnsTransport,
/// Identity of the process that issued the DNS request when the backend
/// can observe it authoritatively. Native socket-write adapters report a
/// resolution error because the relay cannot prove which descriptor
/// holder sent a datagram. Consumers must never treat unavailable
/// identity as a binary-policy grant.
pub binary_identity: Result<BinaryIdentity, ResolveError>,
/// Monotonic stage timing for performance diagnostics.
pub timing: MediationTiming,
/// Single-use response channel owned by the backend adapter.
pub response: oneshot::Sender<Result<Vec<u8>, BackendError>>,
}