mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
* 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>
1076 lines
42 KiB
Rust
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>>,
|
|
}
|