Files
OpenShell/crates/openshell-sandbox-backend/src/boundary_protocol.rs
T
Drew Newberry e5adf6f484 feat(supervisor): recover shared instances with hash-based HA (#4864)
* feat(supervisor): recover shared instances with hash-based HA

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

* refactor(supervisor): keep HA coordination out of compute drivers

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

* fix(supervisor): fence pool admission and reconciliation races

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

* feat(supervisor): keep pool mode stable across replica scaling

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

* refactor(supervisor): default shared deployments to pool mode

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

* fix(supervisor): fence refused launches and remove legacy mode

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

* fix(supervisor): confirm live owners and fence stale observations

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

* fix(supervisor): preserve takeover cause log formatting

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

* feat(helm): add memory-based supervisor autoscaling

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

* fix(supervisor): retain acknowledgements and fence streamed input

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

* docs(supervisor): consolidate experimental multi-tenant guidance

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

* docs(supervisor): simplify multi-tenant supervisor guide

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

* refactor(supervisor): simplify pool member identity

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

* feat(helm): make supervisor affinity configurable

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

* feat(helm): make supervisor disruption budget optional

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

* test(supervisor): align HA fixtures with suspend support

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

---------

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
2026-10-27 04:28:39 +00:00

1966 lines
70 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Versioned control protocol shared by every remote isolation boundary.
//!
//! Drivers choose and provision the transport, but they do not redefine the
//! process lifecycle, streaming, identity, or authentication messages. The
//! supervisor and sandbox exchange typed protobuf messages over gRPC. The
//! length-delimited helpers below adapt only private, in-process Rust streams.
//! The driver chooses the underlying private Unix socket, TCP connection, or virtio-vsock stream.
use std::fmt;
use std::io;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use openshell_core::SandboxSessionId;
use openshell_core::policy::{
FilesystemPolicy, LandlockCompatibility, LandlockPolicy, NetworkMode, NetworkPolicy,
ProcessPolicy, ProxyPolicy, SandboxPolicy,
};
use openshell_isolation_interface::AgentSpec;
use openshell_isolation_interface::contract::Sha256Digest;
use openshell_isolation_interface::contract::{
BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus,
BoundarySignal, ExecSpec, ExecutableIdentity, ProvisioningEvidence, ResolveError, ShellSpec,
};
use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyUsagePurpose};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
pub const MAX_CONTROL_FRAME_BYTES: usize = 1024 * 1024;
/// Exec admission and recovery share one deadline; it never limits process runtime.
pub const EXEC_REQUEST_RETRY_WINDOW: Duration = Duration::from_secs(30);
pub const STREAM_STDIN: u8 = 0;
pub const STREAM_STDOUT: u8 = 1;
pub const STREAM_STDERR: u8 = 2;
pub const STREAM_EXIT: u8 = 3;
pub const STREAM_STDIN_CLOSED: u8 = 4;
/// Supervisor decision for a staged seccomp-mediated TCP open.
pub const STREAM_NETWORK_DECISION: u8 = 5;
pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024;
// Every relay, exec, and control exchange shares one HTTP/2 connection. The
// connection window must exceed what all streams can hold unread, otherwise
// stalled relays starve DNS and control traffic of connection-level credit.
// h2 keeps at least two thirds of `connection - in-flight` advertised, so the
// reserve stays usable even with every stream stalled at its window.
pub const BOUNDARY_MAX_CONCURRENT_STREAMS: u32 = 128;
pub const BOUNDARY_STREAM_WINDOW_BYTES: u32 = 256 * 1024;
pub const BOUNDARY_CONNECTION_WINDOW_RESERVE_BYTES: u32 = 16 * 1024 * 1024;
pub const BOUNDARY_CONNECTION_WINDOW_BYTES: u32 = BOUNDARY_MAX_CONCURRENT_STREAMS
* BOUNDARY_STREAM_WINDOW_BYTES
+ BOUNDARY_CONNECTION_WINDOW_RESERVE_BYTES;
// HTTP/2 caps any flow-control window at 2^31 - 1.
const _: () = assert!(BOUNDARY_CONNECTION_WINDOW_BYTES <= i32::MAX as u32);
/// Capability masks measured from `/proc/<pid>/status` by the `OpenShell`
/// co-located runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityEvidence {
pub inheritable: u64,
pub permitted: u64,
pub effective: u64,
pub bounding: u64,
pub ambient: u64,
}
impl CapabilityEvidence {
#[must_use]
pub const fn is_empty(self) -> bool {
self.inheritable == 0
&& self.permitted == 0
&& self.effective == 0
&& self.bounding == 0
&& self.ambient == 0
}
}
/// Active seccomp notification and socket-broker measurements specific to the
/// `OpenShell` co-located runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[allow(
clippy::struct_excessive_bools,
reason = "each independently measured kernel operation is reported explicitly"
)]
pub struct SeccompEvidence {
pub new_listener: bool,
pub notification_round_trip: bool,
pub id_validation: bool,
pub addfd_send: bool,
pub retained_socket_operation: bool,
pub proc_fd_identity: bool,
pub task_memory_read: bool,
}
/// Mechanism-specific audit evidence for the native Linux sandbox adapter.
///
/// This schema belongs to this backend rather than the generic isolation
/// interface. The host-side backend validates it before constructing a
/// backend-neutral `ConfirmedBoundary`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(
clippy::struct_excessive_bools,
reason = "audit evidence preserves independently measured security results"
)]
pub struct NativeLinuxSandboxAuditEvidence {
pub capabilities: CapabilityEvidence,
pub no_new_privileges: bool,
pub sandbox_dumpable: bool,
pub child_dumpable: bool,
pub core_limit_zero: bool,
pub native_architecture: String,
pub kernel_release: String,
pub seccomp: SeccompEvidence,
pub landlock_abi: u32,
pub landlock_allow_deny: bool,
pub udp_dns_round_trip: bool,
pub tcp_dns_round_trip: bool,
pub tcp_allow_round_trip: bool,
pub tcp_deny_round_trip: bool,
/// Workload INET sockets are bound to loopback before injection, the
/// binding cannot be changed from sandbox credentials, and accepted
/// sockets inherit it. Native local `accept` depends on this property.
pub socket_loopback_confinement: bool,
}
impl NativeLinuxSandboxAuditEvidence {
/// Validate the complete mechanism-specific posture required by this backend.
pub fn validate(&self) -> Result<(), BackendError> {
let complete = self.capabilities.is_empty()
&& self.no_new_privileges
&& !self.sandbox_dumpable
&& self.child_dumpable
&& self.core_limit_zero
&& !self.native_architecture.is_empty()
&& !self.kernel_release.is_empty()
&& self.seccomp.new_listener
&& self.seccomp.notification_round_trip
&& self.seccomp.id_validation
&& self.seccomp.addfd_send
&& self.seccomp.retained_socket_operation
&& self.seccomp.proc_fd_identity
&& self.seccomp.task_memory_read
&& self.landlock_abi >= 3
&& self.landlock_allow_deny
&& self.udp_dns_round_trip
&& self.tcp_dns_round_trip
&& self.tcp_allow_round_trip
&& self.tcp_deny_round_trip
&& self.socket_loopback_confinement;
if complete {
Ok(())
} else {
Err(BackendError::Confirm(
"native Linux sandbox audit evidence is incomplete".to_string(),
))
}
}
}
/// Ephemeral identity of the supervisor process that owns one sandbox runtime.
///
/// The supervisor generates this value in memory and presents it on every
/// attach, including transport reconnects. The sandbox pins the first value it
/// accepts, so a replacement supervisor cannot reuse launch credentials to
/// take over an existing runtime generation. Only a gateway-signed takeover
/// grant for a higher credential epoch, naming the replacement's identity,
/// moves the pin (see [`crate::sandbox_auth`]).
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct SupervisorInstanceId(uuid::Uuid);
impl SupervisorInstanceId {
/// Generate a fresh supervisor-process identity.
#[must_use]
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
}
impl Default for SupervisorInstanceId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for SupervisorInstanceId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.hyphenated().fmt(formatter)
}
}
impl fmt::Debug for SupervisorInstanceId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SupervisorInstanceId([REDACTED])")
}
}
impl FromStr for SupervisorInstanceId {
type Err = SupervisorInstanceIdError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let parsed = uuid::Uuid::parse_str(value).map_err(|_| SupervisorInstanceIdError)?;
if parsed.is_nil() || parsed.hyphenated().to_string() != value {
return Err(SupervisorInstanceIdError);
}
Ok(Self(parsed))
}
}
impl Serialize for SupervisorInstanceId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for SupervisorInstanceId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(|_| {
serde::de::Error::invalid_value(
serde::de::Unexpected::Str(&value),
&"a non-nil canonical lowercase UUID",
)
})
}
}
/// A supervisor instance ID was not a canonical non-nil UUID.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("supervisor instance ID must be a non-nil canonical lowercase UUID")]
pub struct SupervisorInstanceIdError;
/// Backend-owned transport for a driver-provisioned runtime. The serialized
/// variants preserve existing runtime descriptors independently of operator
/// endpoint registration in core.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum SandboxTransport {
Unix {
socket_path: PathBuf,
},
Tcp {
/// Logical HTTP authority used for routing.
authority: String,
/// Concrete connection candidates pinned by the operator or driver.
addresses: Vec<std::net::SocketAddr>,
},
Vsock {
guest_cid: u32,
port: u32,
},
/// The supervisor accepts a connection from its sandbox runtime. TLS and
/// gRPC roles stay unchanged.
RuntimeInitiatedTcp {
authority: String,
listen_address: std::net::SocketAddr,
},
RuntimeInitiatedUnix {
socket_path: PathBuf,
},
RuntimeInitiatedVsock {
guest_cid: u32,
port: u32,
},
/// The runtime dials a supervisor shared by several sandbox instances and
/// names this instance's opaque route handle before the pinned TLS
/// handshake.
/// Only a host that owns the shared listener can serve this transport.
RuntimeInitiatedRouted {
authority: String,
route: String,
},
}
impl SandboxTransport {
pub fn validate(&self) -> Result<(), &'static str> {
let valid = match self {
Self::Unix { socket_path } | Self::RuntimeInitiatedUnix { socket_path } => {
openshell_core::paths::is_posix_absolute(socket_path)
}
Self::Tcp {
authority,
addresses,
} => {
!authority.is_empty()
&& !addresses.is_empty()
&& addresses
.iter()
.all(|address| address.port() != 0 && !address.ip().is_unspecified())
}
Self::RuntimeInitiatedTcp {
authority,
listen_address,
} => !authority.is_empty() && listen_address.port() != 0,
Self::Vsock { guest_cid, port } | Self::RuntimeInitiatedVsock { guest_cid, port } => {
*guest_cid >= 3 && *guest_cid != u32::MAX && *port != 0 && *port != u32::MAX
}
Self::RuntimeInitiatedRouted { authority, route } => {
!authority.is_empty() && crate::runtime_routed::validate_route_handle(route).is_ok()
}
};
if valid {
Ok(())
} else {
Err("invalid boundary control transport endpoint")
}
}
}
impl From<openshell_core::isolation_registration::SandboxTransport> for SandboxTransport {
fn from(endpoint: openshell_core::isolation_registration::SandboxTransport) -> Self {
use openshell_core::isolation_registration::SandboxTransport as RegisteredEndpoint;
match endpoint {
RegisteredEndpoint::Unix { socket_path } => Self::Unix { socket_path },
RegisteredEndpoint::Tcp {
authority,
addresses,
} => Self::Tcp {
authority,
addresses,
},
RegisteredEndpoint::Vsock { guest_cid, port } => Self::Vsock { guest_cid, port },
}
}
}
/// Supervisor-side, generation-pinned TLS server authentication.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SandboxTlsClientConfig {
pub server_name: String,
pub trust_anchor_pem: String,
}
impl fmt::Debug for SandboxTlsClientConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SandboxTlsClientConfig")
.field("server_name", &self.server_name)
.field("trust_anchor_pem", &"<redacted>")
.finish()
}
}
/// Sandbox-side TLS server files staged by the compute driver.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SandboxTlsServerConfig {
pub certificate_chain_path: PathBuf,
pub private_key_path: PathBuf,
}
/// Fresh, server-only TLS material for one sandbox session.
#[derive(Clone)]
pub struct SandboxTlsMaterial {
pub server_name: String,
pub trust_anchor_pem: String,
pub certificate_chain_pem: String,
pub private_key_pem: String,
}
impl fmt::Debug for SandboxTlsMaterial {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SandboxTlsMaterial")
.field("server_name", &self.server_name)
.field("trust_anchor_pem", &"<redacted>")
.field("certificate_chain_pem", &"<redacted>")
.field("private_key_pem", &"<redacted>")
.finish()
}
}
/// Generate a generation-pinned TLS server identity for the Sandbox Protocol.
///
/// The CA private key is local to this function and is discarded after the
/// server leaf is signed. The JWT, rather than certificate time, determines
/// caller authorization and renewal.
pub fn generate_sandbox_tls_material(
session_id: SandboxSessionId,
) -> Result<SandboxTlsMaterial, BackendError> {
let server_name = format!("sandbox.{session_id}.openshell.internal");
let ca_key = openshell_crypto::pki::generate_keypair_for(&rcgen::PKCS_ED25519)
.map_err(|error| BackendError::Descriptor(format!("generate sandbox CA key: {error}")))?;
let mut ca_params = CertificateParams::default();
ca_params.not_before = rcgen::date_time_ymd(1975, 1, 1);
ca_params.not_after = rcgen::date_time_ymd(4096, 1, 1);
ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params
.distinguished_name
.push(DnType::CommonName, "OpenShell sandbox session CA");
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let ca = openshell_crypto::pki::self_signed(ca_params, &ca_key).map_err(|error| {
BackendError::Descriptor(format!("generate sandbox CA certificate: {error}"))
})?;
let sandbox_key =
openshell_crypto::pki::generate_keypair_for(&rcgen::PKCS_ED25519).map_err(|error| {
BackendError::Descriptor(format!("generate sandbox TLS server key: {error}"))
})?;
let mut sandbox_params = CertificateParams::new(vec![server_name.clone()])
.map_err(|error| BackendError::Descriptor(format!("build sandbox certificate: {error}")))?;
sandbox_params.not_before = rcgen::date_time_ymd(1975, 1, 1);
sandbox_params.not_after = rcgen::date_time_ymd(4096, 1, 1);
sandbox_params
.distinguished_name
.push(DnType::CommonName, "OpenShell sandbox runtime");
sandbox_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
let sandbox = openshell_crypto::pki::signed_by(sandbox_params, &sandbox_key, &ca, &ca_key)
.map_err(|error| {
BackendError::Descriptor(format!("sign sandbox TLS server certificate: {error}"))
})?;
Ok(SandboxTlsMaterial {
server_name,
trust_anchor_pem: ca.pem(),
certificate_chain_pem: sandbox.pem(),
private_key_pem: sandbox_key
.serialize_pem()
.map_err(|error| BackendError::Descriptor(format!("export sandbox key: {error}")))?,
})
}
/// Boundary-side listener provisioned by a compute driver.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum BoundaryListener {
/// TLS over a private Unix socket shared with the host supervisor.
Unix {
socket_path: PathBuf,
tls: SandboxTlsServerConfig,
},
/// TLS over TCP. An unspecified IP is valid for the sandbox bind.
TlsTcp {
address: std::net::SocketAddr,
tls: SandboxTlsServerConfig,
},
/// TLS over guest `AF_VSOCK`.
Vsock {
control_port: u32,
tls: SandboxTlsServerConfig,
},
/// The boundary dials the supervisor's listener and serves TLS and gRPC
/// over that outbound connection. Only driver-resolved addresses are used.
RuntimeInitiatedTlsTcp {
/// Stable logical Service authority, for diagnostics.
authority: String,
/// Driver-resolved supervisor endpoints, tried in order.
addresses: Vec<std::net::SocketAddr>,
tls: SandboxTlsServerConfig,
/// Route handle sent before TLS to a supervisor shared by several
/// sandbox instances. Absent for a dedicated supervisor.
#[serde(default, skip_serializing_if = "Option::is_none")]
route: Option<String>,
},
/// Dial the supervisor over the driver's protected shared Unix socket.
RuntimeInitiatedUnix {
socket_path: PathBuf,
tls: SandboxTlsServerConfig,
},
/// Dial the host supervisor through the VM's vsock device.
RuntimeInitiatedVsock {
host_port: u32,
tls: SandboxTlsServerConfig,
},
}
/// Public verification key staged in the sandbox's immutable auth bundle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayVerificationKey {
pub key_id: String,
pub public_key_pem: String,
}
/// Protected runtime descriptor consumed by `openshell-supervisor`.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SandboxRuntimeDescriptor {
/// Stable identity of the boundary, normally the sandbox ID.
pub boundary_id: String,
/// Immutable driver-owned workload generation.
pub generation: String,
/// Fresh gateway-issued identity for this exact launch.
pub session_id: SandboxSessionId,
/// Immutable numeric identity already applied to the sandbox workload.
pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
/// Driver-provisioned byte-stream endpoint.
pub transport: SandboxTransport,
/// Per-generation pinned TLS server identity.
pub tls: SandboxTlsClientConfig,
/// Trusted dial target for well-known host-gateway aliases, when the
/// network supervisor cannot use the boundary's resolver view.
#[serde(default)]
pub host_gateway_ip: Option<std::net::IpAddr>,
/// Driver-specific immutable resource coordinates bound at attach (for
/// example pod UID, VM generation, or container ID).
#[serde(default)]
pub resource_claims: std::collections::BTreeMap<String, String>,
/// Backend-neutral projection of the validated outer network fence.
pub provisioning_evidence: ProvisioningEvidence,
}
impl fmt::Debug for SandboxRuntimeDescriptor {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SandboxRuntimeDescriptor")
.field("boundary_id", &self.boundary_id)
.field("generation", &self.generation)
.field("session_id", &self.session_id)
.field("transport", &self.transport)
.field("tls", &self.tls)
.field("host_gateway_ip", &self.host_gateway_ip)
.field("resource_claims", &self.resource_claims)
.field("provisioning_evidence", &self.provisioning_evidence)
.finish()
}
}
impl SandboxRuntimeDescriptor {
/// Encode this runtime configuration for the `openshell-sandbox` backend.
pub fn backend_descriptor(&self) -> Result<BackendDescriptor, BackendError> {
let payload = serde_json::to_vec(self).map_err(|error| {
BackendError::Descriptor(format!("encode runtime descriptor: {error}"))
})?;
Ok(BackendDescriptor {
backend_name: crate::BACKEND_NAME.to_string(),
payload,
})
}
}
/// Protected bootstrap configuration consumed by `openshell-sandbox`.
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BoundaryConfig {
/// Stable identity expected in every authenticated request.
pub boundary_id: String,
/// Immutable driver-owned workload generation.
pub generation: String,
/// Fresh gateway-issued identity for this exact launch.
pub session_id: SandboxSessionId,
/// Monotonic signed supervisor replacement within this generation.
pub session_rotation: openshell_core::jwt::SessionRotation,
/// Durable authorization epoch required in every Sandbox Protocol JWT.
pub auth_epoch: openshell_core::jwt::CredentialEpoch,
/// Gateway identity expected in Sandbox Protocol JWTs.
pub gateway_id: String,
/// Immutable current and staged-next gateway verification keys.
pub verification_keys: Vec<GatewayVerificationKey>,
/// Driver-provisioned listener.
pub listener: BoundaryListener,
/// Immutable coordinates the boundary requires from the control-side
/// runtime descriptor before accepting attachment.
#[serde(default)]
pub resource_claims: std::collections::BTreeMap<String, String>,
/// Driver-provisioned, read-only runtime evidence for resource claims.
///
/// Each entry maps a claim key to an absolute file whose trimmed contents
/// must equal the corresponding value in `resource_claims` before the
/// boundary opens its listener. Kubernetes uses this to bind a one-use
/// bootstrap bundle to the admitted workload Pod UID exposed by the
/// Downward API. Other drivers may leave the map empty.
#[serde(default)]
pub resource_claim_files: std::collections::BTreeMap<String, PathBuf>,
/// Exact identity already applied by the runtime to the sandbox process.
pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
/// Backend-neutral projection of the validated outer network fence.
pub provisioning_evidence: ProvisioningEvidence,
/// Driver-resolved environment exposed only to workload processes.
#[serde(default)]
pub child_env: std::collections::HashMap<String, String>,
}
/// Default time a runtime keeps a workload frozen, after losing its
/// supervisor, for an authenticated supervisor to recover it.
pub const DEFAULT_SUPERVISOR_RECOVERY_TIMEOUT: Duration = Duration::from_secs(30);
/// Accepted bounds, in seconds, of a recovery deadline a supervisor offers.
///
/// A supervisor offers it at attach (see [`Request::Attach`]). Recovery is
/// always bounded: a workload never runs unsupervised, and a frozen workload
/// is terminated once its deadline passes.
pub const SUPERVISOR_RECOVERY_TIMEOUT_RANGE: std::ops::RangeInclusive<u32> = 10..=300;
/// The recovery deadline of an offer of `seconds`, if
/// [`SUPERVISOR_RECOVERY_TIMEOUT_RANGE`] allows it.
#[must_use]
pub fn supervisor_recovery_timeout(seconds: u32) -> Option<Duration> {
SUPERVISOR_RECOVERY_TIMEOUT_RANGE
.contains(&seconds)
.then(|| Duration::from_secs(u64::from(seconds)))
}
impl fmt::Debug for BoundaryConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BoundaryConfig")
.field("boundary_id", &self.boundary_id)
.field("generation", &self.generation)
.field("session_id", &self.session_id)
.field("session_rotation", &self.session_rotation)
.field("auth_epoch", &self.auth_epoch)
.field("gateway_id", &self.gateway_id)
.field(
"verification_key_ids",
&self
.verification_keys
.iter()
.map(|key| key.key_id.as_str())
.collect::<Vec<_>>(),
)
.field("listener", &self.listener)
.field("resource_claims", &self.resource_claims)
.field("resource_claim_files", &self.resource_claim_files)
.field("workload_identity", &self.workload_identity)
.field("provisioning_evidence", &self.provisioning_evidence)
.field("child_env_keys", &self.child_env.keys().collect::<Vec<_>>())
.finish()
}
}
impl BoundaryConfig {
/// Serialize the protected driver-owned boundary configuration.
pub fn encode(&self) -> Result<Vec<u8>, BackendError> {
serde_json::to_vec(self)
.map_err(|error| BackendError::Descriptor(format!("encode boundary config: {error}")))
}
}
/// Validate driver-specific immutable coordinates before a boundary binds them.
///
/// Claim values are opaque to the common protocol, but empty or
/// whitespace-bearing identifiers cannot safely distinguish runtime objects.
pub fn validate_resource_claims(
claims: &std::collections::BTreeMap<String, String>,
) -> Result<(), BackendError> {
for (key, value) in claims {
if key.is_empty() || key.chars().any(char::is_whitespace) {
return Err(BackendError::Descriptor(
"boundary resource-claim keys must be non-empty and contain no whitespace"
.to_string(),
));
}
if value.is_empty() || value.chars().any(char::is_whitespace) {
return Err(BackendError::Descriptor(format!(
"boundary resource claim {key:?} must be non-empty and contain no whitespace"
)));
}
}
Ok(())
}
pub async fn write_stream_frame(
writer: &mut (impl AsyncWrite + Unpin),
channel: u8,
payload: &[u8],
) -> io::Result<()> {
if payload.len() > MAX_STREAM_FRAME_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"boundary stream frame exceeds limit",
));
}
writer.write_u8(channel).await?;
writer
.write_u32(payload.len().try_into().map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"boundary stream frame length overflow",
)
})?)
.await?;
writer.write_all(payload).await?;
writer.flush().await
}
pub async fn read_stream_frame(
reader: &mut (impl AsyncRead + Unpin),
) -> io::Result<Option<(u8, Vec<u8>)>> {
let channel = match reader.read_u8().await {
Ok(channel) => channel,
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(error) => return Err(error),
};
let declared = reader.read_u32().await? as usize;
if declared > MAX_STREAM_FRAME_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("boundary stream frame is too large: {declared} bytes"),
));
}
let mut payload = vec![0; declared];
reader.read_exact(&mut payload).await?;
Ok(Some((channel, payload)))
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequestEnvelope {
/// Cryptographically random idempotency key scoped to one sandbox generation.
pub request_id: String,
/// Local replay fingerprint of decoded protobuf fields; never transmitted.
pub payload_digest: String,
/// Absolute exec admission deadline, preserved across retries and bound to
/// the payload digest. Other request kinds do not expire through this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_admission_deadline_unix_ms: Option<u64>,
pub request: Request,
}
impl RequestEnvelope {
/// Build a request envelope with a fresh idempotency key and normalized
/// payload digest.
pub fn new(request: Request) -> Result<Self, FrameError> {
let exec_admission_deadline_unix_ms = if matches!(request, Request::Exec { .. }) {
let window_ms =
u64::try_from(EXEC_REQUEST_RETRY_WINDOW.as_millis()).map_err(io::Error::other)?;
Some(unix_time_millis()?.saturating_add(window_ms))
} else {
None
};
let payload_digest = request_envelope_digest(&request, exec_admission_deadline_unix_ms)?;
Ok(Self {
request_id: uuid::Builder::from_random_bytes(openshell_crypto::random_bytes::<16>()?)
.into_uuid()
.to_string(),
payload_digest,
exec_admission_deadline_unix_ms,
request,
})
}
/// Verify that the request body still matches the immutable digest bound
/// to this idempotency key.
pub fn validate_payload_digest(&self) -> Result<(), FrameError> {
let actual = request_envelope_digest(&self.request, self.exec_admission_deadline_unix_ms)?;
if actual == self.payload_digest {
Ok(())
} else {
Err(FrameError::PayloadDigestMismatch)
}
}
}
/// Shared wall clock for the host's deadline and the boundary's admission check.
pub fn unix_time_millis() -> Result<u64, FrameError> {
let elapsed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(io::Error::other)?;
u64::try_from(elapsed.as_millis()).map_err(|error| FrameError::Io(io::Error::other(error)))
}
pub(crate) fn request_envelope_digest(
request: &Request,
exec_admission_deadline_unix_ms: Option<u64>,
) -> Result<String, FrameError> {
use std::fmt::Write as _;
// Stable local replay fingerprint of decoded protobuf fields. Maps use
// BTreeMap in generated messages; neither clients nor the wire carry this.
let envelope = RequestEnvelope {
request_id: String::new(),
payload_digest: String::new(),
exec_admission_deadline_unix_ms,
request: request.clone(),
};
let payload = crate::protobuf::encode(&envelope)?;
let digest = openshell_crypto::sha256(&payload)?;
let mut encoded = String::with_capacity(64);
for byte in digest {
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
Ok(encoded)
}
impl fmt::Debug for RequestEnvelope {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RequestEnvelope")
.field("request_id", &self.request_id)
.field("request", &self.request)
.finish()
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "snake_case")]
pub enum Request {
/// Read image policy without attaching or launching a workload.
DiscoverPolicy,
Attach {
supervisor_instance_id: SupervisorInstanceId,
policy: Box<SandboxPolicyWire>,
/// Gateway-signed takeover grant, present only when this process
/// replaces the generation's current owner.
#[serde(default, skip_serializing_if = "Option::is_none")]
supervisor_takeover: Option<String>,
/// Seconds the runtime should keep the workload frozen after losing
/// this connection's supervision, offered by the supervisor and
/// bounded by [`SUPERVISOR_RECOVERY_TIMEOUT_RANGE`]. Absent keeps
/// [`DEFAULT_SUPERVISOR_RECOVERY_TIMEOUT`].
#[serde(default, skip_serializing_if = "Option::is_none")]
supervisor_recovery_timeout_secs: Option<u32>,
},
Confirm,
StartAgent {
sandbox_id: String,
spec: AgentSpecWire,
ca_cert: Option<String>,
ca_bundle: Option<String>,
provider_env_revision: u64,
provider_env: std::collections::HashMap<String, String>,
#[serde(default)]
provider_files: std::collections::HashMap<String, String>,
},
UpdateProviderEnvironment {
/// Ordered publication within this authenticated boundary session.
generation: u64,
revision: u64,
provider_env: std::collections::HashMap<String, String>,
#[serde(default)]
provider_files: std::collections::HashMap<String, String>,
},
AttachProcess {
process_id: String,
},
Wait {
process_id: String,
},
Signal {
process_id: String,
signal: SignalWire,
},
Terminate {
process_id: String,
},
/// Permanently end this sandbox session and acknowledge that every owned
/// workload process has been terminated.
TerminateBoundary,
Exec {
spec: ExecSpecWire,
},
Resize {
process_id: String,
cols: u16,
rows: u16,
},
LoopbackConnect {
host: std::net::IpAddr,
port: u16,
},
/// Upgrade one authenticated logical stream into the persistent DNS data
/// plane.
OpenMediation,
AcceptNetwork,
}
impl Request {
/// Whether this control-path request changes generation-owned sandbox
/// state and therefore must be replayed from the idempotency ledger.
#[must_use]
pub const fn is_replayable_mutation(&self) -> bool {
matches!(
self,
Self::Attach { .. }
| Self::Confirm
| Self::StartAgent { .. }
| Self::UpdateProviderEnvironment { .. }
| Self::Exec { .. }
| Self::Signal { .. }
| Self::Terminate { .. }
| Self::TerminateBoundary
| Self::Resize { .. }
)
}
}
impl fmt::Debug for Request {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DiscoverPolicy => formatter.write_str("DiscoverPolicy"),
Self::Attach {
supervisor_instance_id: _,
policy: _,
supervisor_takeover,
supervisor_recovery_timeout_secs,
} => formatter
.debug_struct("Attach")
.field("policy", &"<redacted>")
.field("takeover", &supervisor_takeover.is_some())
.field(
"supervisor_recovery_timeout_secs",
supervisor_recovery_timeout_secs,
)
.finish(),
Self::Confirm => formatter.write_str("Confirm"),
Self::StartAgent {
sandbox_id,
spec,
ca_cert,
ca_bundle,
provider_env_revision,
provider_env,
provider_files,
} => formatter
.debug_struct("StartAgent")
.field("sandbox_id", sandbox_id)
.field("spec", spec)
.field("ca_cert_present", &ca_cert.is_some())
.field("ca_bundle_present", &ca_bundle.is_some())
.field("provider_env_revision", provider_env_revision)
.field("provider_file_count", &provider_files.len())
.field(
"provider_env_keys",
&provider_env.keys().collect::<Vec<_>>(),
)
.finish(),
Self::UpdateProviderEnvironment {
generation,
revision,
provider_env,
provider_files,
} => formatter
.debug_struct("UpdateProviderEnvironment")
.field("generation", generation)
.field("revision", revision)
.field("provider_file_count", &provider_files.len())
.field(
"provider_env_keys",
&provider_env.keys().collect::<Vec<_>>(),
)
.finish(),
Self::Wait { process_id } => formatter
.debug_struct("Wait")
.field("process_id", process_id)
.finish(),
Self::AttachProcess { process_id } => formatter
.debug_struct("AttachProcess")
.field("process_id", process_id)
.finish(),
Self::Signal { process_id, signal } => formatter
.debug_struct("Signal")
.field("process_id", process_id)
.field("signal", signal)
.finish(),
Self::Terminate { process_id } => formatter
.debug_struct("Terminate")
.field("process_id", process_id)
.finish(),
Self::TerminateBoundary => formatter.write_str("TerminateBoundary"),
Self::Exec { spec } => formatter.debug_tuple("Exec").field(spec).finish(),
Self::Resize {
process_id,
cols,
rows,
} => formatter
.debug_struct("Resize")
.field("process_id", process_id)
.field("cols", cols)
.field("rows", rows)
.finish(),
Self::LoopbackConnect { host, port } => formatter
.debug_struct("LoopbackConnect")
.field("host", host)
.field("port", port)
.finish(),
Self::OpenMediation => formatter.write_str("OpenMediation"),
Self::AcceptNetwork => formatter.write_str("AcceptNetwork"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResponseEnvelope {
pub request_id: String,
pub response: Response,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum Response {
ImagePolicy {
yaml: Option<String>,
invalid: bool,
},
Attached {
snapshot: SessionSnapshotWire,
},
Confirmed {
/// Backend-neutral properties and backend-owned audit evidence produced
/// before workload launch.
confirmation: Box<BoundaryConfirmation>,
},
Started {
process_id: String,
provider_env_revision: u64,
provider_env_generation: u64,
},
ProviderEnvironmentUpdated {
revision: u64,
generation: u64,
/// True only for this installed request or its exact idempotent replay.
applied: bool,
},
ProcessAttached {
terminal: bool,
},
Exited {
status: ExitStatusWire,
},
Signaled,
Terminated,
BoundaryTerminated,
ExecStarted {
process_id: String,
pty: bool,
},
Resized,
LoopbackConnected,
MediationReady,
NetworkConnected {
identity: BinaryIdentityWire,
destination: std::net::SocketAddr,
/// Native evidence is absent for guest host calls without kernel sockets.
#[serde(default, skip_serializing_if = "Option::is_none")]
socket: Option<openshell_isolation_interface::contract::NetworkSocketMetadata>,
policy_generation: u64,
timing: MediationTimingWire,
},
Error {
kind: BoundaryErrorKind,
message: String,
},
}
/// Sandbox-monotonic timing carried across the boundary protocol.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MediationTimingWire {
pub notification_to_queue_us: u64,
pub queue_wait_us: u64,
}
/// Boundary-owned process/session state returned on every supervisor attach.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionSnapshotWire {
pub generation: String,
pub processes: Vec<ProcessSnapshotWire>,
/// Launch controls bound by the first attach, if any. A replacement
/// supervisor reattaches with exactly these.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bound_policy: Option<Box<SandboxPolicyWire>>,
}
/// Stable generation-scoped process state available to a replacement supervisor.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessSnapshotWire {
pub process_id: String,
pub kind: ProcessKindWire,
pub terminal: bool,
pub status: Option<ExitStatusWire>,
pub retained_output: OutputWindowWire,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcessKindWire {
Main,
Exec,
}
/// Sequence range retained by the sandbox output ring.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutputWindowWire {
pub first_sequence: u64,
pub next_sequence: u64,
pub truncated: bool,
}
/// Completion of one sandbox-local DNS relay exchange.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", content = "value", rename_all = "snake_case")]
pub enum DnsQueryResultWire {
Response(Vec<u8>),
Error(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryErrorKind {
Invalid,
Denied,
Unavailable,
Terminated,
Process,
Unsupported,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecutableIdentityWire {
pub path: PathBuf,
pub digest: Option<Sha256Digest>,
}
impl From<ExecutableIdentity> for ExecutableIdentityWire {
fn from(identity: ExecutableIdentity) -> Self {
Self {
path: identity.path,
digest: identity.digest,
}
}
}
impl From<ExecutableIdentityWire> for ExecutableIdentity {
fn from(identity: ExecutableIdentityWire) -> Self {
Self {
path: identity.path,
digest: identity.digest,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case", deny_unknown_fields)]
pub enum BinaryIdentityWire {
Resolved {
executable: ExecutableIdentityWire,
ancestors: Vec<ExecutableIdentityWire>,
cmdline_paths: Vec<PathBuf>,
},
Failed {
message: String,
},
}
impl From<Result<BinaryIdentity, ResolveError>> for BinaryIdentityWire {
fn from(identity: Result<BinaryIdentity, ResolveError>) -> Self {
match identity {
Ok(identity) => Self::Resolved {
executable: identity.executable.into(),
ancestors: identity.ancestors.into_iter().map(Into::into).collect(),
cmdline_paths: identity.cmdline_paths,
},
Err(error) => Self::Failed {
message: error.to_string(),
},
}
}
}
impl BinaryIdentityWire {
pub fn into_result(self) -> Result<BinaryIdentity, ResolveError> {
match self {
Self::Resolved {
executable,
ancestors,
cmdline_paths,
} => Ok(BinaryIdentity {
executable: executable.into(),
ancestors: ancestors.into_iter().map(Into::into).collect(),
cmdline_paths,
}),
Self::Failed { message } => Err(ResolveError::Failed(message)),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecSpecWire {
pub program: String,
pub args: Vec<String>,
#[serde(default)]
pub shell: Option<ShellSpecWire>,
#[serde(default)]
pub runtime_helper: Option<RuntimeHelperWire>,
pub env: Vec<(String, String)>,
pub workdir: Option<String>,
pub pty: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeHelperWire {
Sftp,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShellSpecWire {
pub command: Option<String>,
pub login: bool,
}
impl From<ShellSpec> for ShellSpecWire {
fn from(spec: ShellSpec) -> Self {
Self {
command: spec.command,
login: spec.login,
}
}
}
impl From<ShellSpecWire> for ShellSpec {
fn from(spec: ShellSpecWire) -> Self {
Self {
command: spec.command,
login: spec.login,
}
}
}
impl From<ExecSpec> for ExecSpecWire {
fn from(spec: ExecSpec) -> Self {
Self {
program: spec.program,
args: spec.args,
shell: spec.shell.map(ShellSpecWire::from),
runtime_helper: spec.runtime_helper.map(|helper| match helper {
openshell_isolation_interface::contract::RuntimeHelper::Sftp => {
RuntimeHelperWire::Sftp
}
}),
env: spec.env,
workdir: spec.workdir,
pty: spec.pty,
}
}
}
impl From<ExecSpecWire> for ExecSpec {
fn from(spec: ExecSpecWire) -> Self {
Self {
program: spec.program,
args: spec.args,
shell: spec.shell.map(ShellSpec::from),
runtime_helper: spec.runtime_helper.map(|helper| match helper {
RuntimeHelperWire::Sftp => {
openshell_isolation_interface::contract::RuntimeHelper::Sftp
}
}),
env: spec.env,
workdir: spec.workdir,
pty: spec.pty,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSpecWire {
pub program: String,
pub args: Vec<String>,
pub workdir: Option<String>,
pub timeout_secs: u64,
pub interactive: bool,
}
impl From<AgentSpec> for AgentSpecWire {
fn from(spec: AgentSpec) -> Self {
Self {
program: spec.program,
args: spec.args,
workdir: spec.workdir,
timeout_secs: spec.timeout_secs,
interactive: spec.interactive,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SandboxPolicyWire {
pub version: u32,
pub read_only: Vec<PathBuf>,
pub read_write: Vec<PathBuf>,
pub include_workdir: bool,
pub network: NetworkModeWire,
pub proxy_addr: Option<std::net::SocketAddr>,
pub landlock: LandlockCompatibilityWire,
pub run_as_user: Option<String>,
pub run_as_group: Option<String>,
}
impl From<SandboxPolicy> for SandboxPolicyWire {
fn from(policy: SandboxPolicy) -> Self {
// Exhaustively destructure the policy so adding a `SandboxPolicy`
// field is a compile error here instead of a silently dropped field
// across the host-to-guest trust boundary.
let SandboxPolicy {
version,
filesystem,
network,
landlock,
process,
} = policy;
let FilesystemPolicy {
read_only,
read_write,
include_workdir,
} = filesystem;
let NetworkPolicy { mode, proxy } = network;
let LandlockPolicy { compatibility } = landlock;
let ProcessPolicy {
run_as_user,
run_as_group,
} = process;
Self {
version,
read_only,
read_write,
include_workdir,
network: NetworkModeWire::from(mode),
proxy_addr: proxy.and_then(|proxy| proxy.http_addr),
landlock: LandlockCompatibilityWire::from(compatibility),
run_as_user,
run_as_group,
}
}
}
impl From<SandboxPolicyWire> for SandboxPolicy {
fn from(policy: SandboxPolicyWire) -> Self {
let proxy = matches!(policy.network, NetworkModeWire::Proxy).then_some(ProxyPolicy {
http_addr: policy.proxy_addr,
});
Self {
version: policy.version,
filesystem: FilesystemPolicy {
read_only: policy.read_only,
read_write: policy.read_write,
include_workdir: policy.include_workdir,
},
network: NetworkPolicy {
mode: policy.network.into(),
proxy,
},
landlock: LandlockPolicy {
compatibility: policy.landlock.into(),
},
process: ProcessPolicy {
run_as_user: policy.run_as_user,
run_as_group: policy.run_as_group,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetworkModeWire {
Block,
Proxy,
Allow,
}
impl From<NetworkMode> for NetworkModeWire {
fn from(mode: NetworkMode) -> Self {
match mode {
NetworkMode::Block => Self::Block,
NetworkMode::Proxy => Self::Proxy,
NetworkMode::Allow => Self::Allow,
}
}
}
impl From<NetworkModeWire> for NetworkMode {
fn from(mode: NetworkModeWire) -> Self {
match mode {
NetworkModeWire::Block => Self::Block,
NetworkModeWire::Proxy => Self::Proxy,
NetworkModeWire::Allow => Self::Allow,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LandlockCompatibilityWire {
BestEffort,
HardRequirement,
}
impl From<LandlockCompatibility> for LandlockCompatibilityWire {
fn from(compatibility: LandlockCompatibility) -> Self {
match compatibility {
LandlockCompatibility::BestEffort => Self::BestEffort,
LandlockCompatibility::HardRequirement => Self::HardRequirement,
}
}
}
impl From<LandlockCompatibilityWire> for LandlockCompatibility {
fn from(compatibility: LandlockCompatibilityWire) -> Self {
match compatibility {
LandlockCompatibilityWire::BestEffort => Self::BestEffort,
LandlockCompatibilityWire::HardRequirement => Self::HardRequirement,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalWire {
Term,
Kill,
Int,
Hup,
}
impl From<BoundarySignal> for SignalWire {
fn from(signal: BoundarySignal) -> Self {
match signal {
BoundarySignal::Term => Self::Term,
BoundarySignal::Kill => Self::Kill,
BoundarySignal::Int => Self::Int,
BoundarySignal::Hup => Self::Hup,
}
}
}
impl From<SignalWire> for BoundarySignal {
fn from(signal: SignalWire) -> Self {
match signal {
SignalWire::Term => Self::Term,
SignalWire::Kill => Self::Kill,
SignalWire::Int => Self::Int,
SignalWire::Hup => Self::Hup,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum ExitStatusWire {
Exited(i32),
Signaled(i32),
}
impl From<ExitStatusWire> for BoundaryExitStatus {
fn from(status: ExitStatusWire) -> Self {
match status {
ExitStatusWire::Exited(code) => Self::Exited(code),
ExitStatusWire::Signaled(signal) => Self::Signaled(signal),
}
}
}
impl From<BoundaryExitStatus> for ExitStatusWire {
fn from(status: BoundaryExitStatus) -> Self {
match status {
BoundaryExitStatus::Exited(code) => Self::Exited(code),
BoundaryExitStatus::Signaled(signal) => Self::Signaled(signal),
}
}
}
pub fn encode_frame<T: crate::protobuf::WireMessage>(message: &T) -> Result<Vec<u8>, FrameError>
where
T::Proto: prost::Message + Default,
{
let payload = crate::protobuf::encode(message)?;
if payload.len() > MAX_CONTROL_FRAME_BYTES {
return Err(FrameError::TooLarge(payload.len()));
}
let length = u32::try_from(payload.len()).map_err(|_| FrameError::TooLarge(payload.len()))?;
let mut frame = Vec::with_capacity(4 + payload.len());
frame.extend_from_slice(&length.to_be_bytes());
frame.extend_from_slice(&payload);
Ok(frame)
}
pub fn decode_frame<T: crate::protobuf::WireMessage>(frame: &[u8]) -> Result<T, FrameError>
where
T::Proto: prost::Message + Default,
{
let header: [u8; 4] = frame
.get(..4)
.ok_or(FrameError::Truncated)?
.try_into()
.map_err(|_| FrameError::Truncated)?;
let declared = u32::from_be_bytes(header) as usize;
if declared > MAX_CONTROL_FRAME_BYTES {
return Err(FrameError::TooLarge(declared));
}
let payload = frame.get(4..).ok_or(FrameError::Truncated)?;
if payload.len() != declared {
return Err(FrameError::LengthMismatch {
declared,
actual: payload.len(),
});
}
crate::protobuf::decode(payload)
}
pub fn read_frame<T: crate::protobuf::WireMessage>(reader: &mut impl Read) -> Result<T, FrameError>
where
T::Proto: prost::Message + Default,
{
let mut header = [0_u8; 4];
reader.read_exact(&mut header)?;
let declared = u32::from_be_bytes(header) as usize;
if declared > MAX_CONTROL_FRAME_BYTES {
return Err(FrameError::TooLarge(declared));
}
let mut frame = Vec::with_capacity(4 + declared);
frame.extend_from_slice(&header);
frame.resize(4 + declared, 0);
reader.read_exact(&mut frame[4..])?;
decode_frame(&frame)
}
pub async fn read_frame_async<R, T>(reader: &mut R) -> Result<T, FrameError>
where
R: AsyncRead + Unpin,
T: crate::protobuf::WireMessage,
T::Proto: prost::Message + Default,
{
use tokio::io::AsyncReadExt as _;
let mut header = [0_u8; 4];
reader.read_exact(&mut header).await?;
let declared = u32::from_be_bytes(header) as usize;
if declared > MAX_CONTROL_FRAME_BYTES {
return Err(FrameError::TooLarge(declared));
}
let mut frame = Vec::with_capacity(4 + declared);
frame.extend_from_slice(&header);
frame.resize(4 + declared, 0);
reader.read_exact(&mut frame[4..]).await?;
decode_frame(&frame)
}
pub fn write_frame<T: crate::protobuf::WireMessage>(
writer: &mut impl Write,
message: &T,
) -> Result<(), FrameError>
where
T::Proto: prost::Message + Default,
{
let frame = encode_frame(message)?;
writer.write_all(&frame)?;
writer.flush()?;
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum FrameError {
#[error("control frame cryptography failed: {0}")]
Crypto(#[from] openshell_crypto::CryptoError),
#[error("invalid protobuf message: {0}")]
Protobuf(String),
#[error("control frame is truncated")]
Truncated,
#[error("control frame is too large: {0} bytes")]
TooLarge(usize),
#[error("control frame declared {declared} bytes but contained {actual}")]
LengthMismatch { declared: usize, actual: usize },
#[error("control request payload digest does not match its envelope")]
PayloadDigestMismatch,
#[error("read or write control frame: {0}")]
Io(#[from] io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence {
NativeLinuxSandboxAuditEvidence {
capabilities: CapabilityEvidence {
inheritable: 0,
permitted: 0,
effective: 0,
bounding: 0,
ambient: 0,
},
no_new_privileges: true,
sandbox_dumpable: false,
child_dumpable: true,
core_limit_zero: true,
native_architecture: "x86_64".to_string(),
kernel_release: "6.12.0".to_string(),
seccomp: SeccompEvidence {
new_listener: true,
notification_round_trip: true,
id_validation: true,
addfd_send: true,
retained_socket_operation: true,
proc_fd_identity: true,
task_memory_read: true,
},
landlock_abi: 6,
landlock_allow_deny: true,
udp_dns_round_trip: true,
tcp_dns_round_trip: true,
tcp_allow_round_trip: true,
tcp_deny_round_trip: true,
socket_loopback_confinement: true,
}
}
#[test]
fn native_linux_audit_evidence_rejects_mechanism_failure() {
let mut audit = complete_audit_evidence();
audit.seccomp.addfd_send = false;
assert!(audit.validate().is_err());
}
#[test]
fn audit_evidence_requires_socket_loopback_confinement() {
let mut audit = complete_audit_evidence();
audit.socket_loopback_confinement = false;
assert!(audit.validate().is_err());
}
#[test]
fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() {
for encoded in [
r#"{"result":"resolved","ancestors":[],"cmdline_paths":[]}"#,
r#"{"result":"resolved","executable":{"path":"/bin/tool","digest":"invalid"},"ancestors":[],"cmdline_paths":[]}"#,
r#"{"result":"resolved","binary_path":"/bin/tool","binary_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ancestors":[],"cmdline_paths":[]}"#,
r#"{"result":"failed","message":"unavailable","binary_path":"/bin/tool"}"#,
] {
assert!(serde_json::from_str::<BinaryIdentityWire>(encoded).is_err());
}
let identity = BinaryIdentityWire::from(Ok(BinaryIdentity {
executable: ExecutableIdentity {
path: PathBuf::from("/bin/tool"),
digest: Some("a".repeat(64).parse().unwrap()),
},
ancestors: vec![ExecutableIdentity {
path: PathBuf::from("/bin/launcher"),
digest: Some("b".repeat(64).parse().unwrap()),
}],
cmdline_paths: Vec::new(),
}));
let encoded = serde_json::to_vec(&identity).unwrap();
assert_eq!(
serde_json::from_slice::<BinaryIdentityWire>(&encoded).unwrap(),
identity
);
}
#[test]
fn boundary_error_kind_rejects_unknown_values() {
assert!(serde_json::from_str::<BoundaryErrorKind>(r#""unkown""#).is_err());
for kind in [
BoundaryErrorKind::Invalid,
BoundaryErrorKind::Denied,
BoundaryErrorKind::Unavailable,
BoundaryErrorKind::Terminated,
BoundaryErrorKind::Process,
] {
let encoded = serde_json::to_vec(&kind).unwrap();
assert_eq!(
serde_json::from_slice::<BoundaryErrorKind>(&encoded).unwrap(),
kind
);
}
}
#[test]
fn supervisor_instance_id_round_trips_canonically_and_stays_redacted() {
let instance_id = SupervisorInstanceId::new();
let encoded = serde_json::to_string(&instance_id).expect("encode supervisor instance ID");
let decoded = serde_json::from_str::<SupervisorInstanceId>(&encoded)
.expect("decode supervisor instance ID");
assert_eq!(decoded, instance_id);
assert_eq!(
format!("{instance_id:?}"),
"SupervisorInstanceId([REDACTED])"
);
assert!(
serde_json::from_str::<SupervisorInstanceId>(
r#""00000000-0000-0000-0000-000000000000""#
)
.is_err()
);
}
#[test]
fn request_round_trips_and_redacts_secrets() {
let request = RequestEnvelope {
request_id: "4e94636d-54f8-4d85-8e4e-58954fb5af0a".to_string(),
payload_digest: String::new(),
exec_admission_deadline_unix_ms: None,
request: Request::StartAgent {
provider_files: std::collections::HashMap::new(),
sandbox_id: "sandbox-1".to_string(),
spec: AgentSpecWire {
program: "/bin/true".to_string(),
args: Vec::new(),
workdir: Some("/sandbox".to_string()),
timeout_secs: 5,
interactive: false,
},
ca_cert: Some("test certificate".to_string()),
ca_bundle: Some("test bundle".to_string()),
provider_env_revision: 7,
provider_env: std::collections::HashMap::from([(
"OPENAI_API_KEY".to_string(),
"test credential".to_string(),
)]),
},
};
let request = RequestEnvelope {
payload_digest: request_envelope_digest(&request.request, None)
.expect("request digest"),
..request
};
let frame = encode_frame(&request).expect("encode request");
let decoded: RequestEnvelope = decode_frame(&frame).expect("decode request");
assert_eq!(decoded, request);
let debug = format!("{request:?}");
assert!(!debug.contains("test credential"));
assert!(!debug.contains("test certificate"));
assert!(!debug.contains("test bundle"));
assert!(debug.contains("OPENAI_API_KEY"));
assert!(request.validate_payload_digest().is_ok());
}
#[test]
fn exec_deadline_round_trips_and_cannot_be_extended_on_retry() {
let before = unix_time_millis().unwrap();
let request = RequestEnvelope::new(Request::Exec {
spec: ExecSpecWire {
program: "/bin/true".to_string(),
args: Vec::new(),
shell: None,
runtime_helper: None,
env: Vec::new(),
workdir: None,
pty: false,
},
})
.unwrap();
let after = unix_time_millis().unwrap();
let window_ms = u64::try_from(EXEC_REQUEST_RETRY_WINDOW.as_millis()).unwrap();
let deadline = request.exec_admission_deadline_unix_ms.unwrap();
assert!((before + window_ms..=after + window_ms).contains(&deadline));
let frame = encode_frame(&request).unwrap();
let mut retry: RequestEnvelope = decode_frame(&frame).unwrap();
assert_eq!(retry, request);
retry.validate_payload_digest().unwrap();
retry.exec_admission_deadline_unix_ms = Some(deadline + 1);
assert!(matches!(
retry.validate_payload_digest(),
Err(FrameError::PayloadDigestMismatch)
));
retry.exec_admission_deadline_unix_ms = None;
assert!(retry.validate_payload_digest().is_err());
}
#[test]
fn non_exec_requests_have_no_deadline() {
let envelope = RequestEnvelope::new(Request::Confirm).unwrap();
assert!(envelope.exec_admission_deadline_unix_ms.is_none());
let encoded = serde_json::to_value(&envelope).unwrap();
assert!(encoded.get("exec_admission_deadline_unix_ms").is_none());
let decoded: RequestEnvelope = serde_json::from_value(encoded).unwrap();
decoded.validate_payload_digest().unwrap();
}
#[test]
fn request_digest_is_stable_across_map_order_and_detects_mutation() {
let mut first = std::collections::HashMap::new();
first.insert("B".to_string(), "2".to_string());
first.insert("A".to_string(), "1".to_string());
let mut second = std::collections::HashMap::new();
second.insert("A".to_string(), "1".to_string());
second.insert("B".to_string(), "2".to_string());
let build = |provider_env| Request::UpdateProviderEnvironment {
provider_files: std::collections::HashMap::new(),
generation: 1,
revision: 2,
provider_env,
};
// The fingerprint is local: independently ordered protobuf maps must
// compare equally, while changed decoded fields must not replay.
let expected = request_envelope_digest(&build(first.clone()), None).unwrap();
for provider_env in [first, second] {
let request = build(provider_env);
assert_eq!(
request_envelope_digest(&request, None).expect("digest"),
expected
);
// Deserialization reconstructs the map with an independent hash
// seed; validation must retain the sender's canonical digest.
let envelope = RequestEnvelope::new(request).expect("request envelope");
let frame = encode_frame(&envelope).expect("encode envelope");
let mut decoded: RequestEnvelope = decode_frame(&frame).expect("decode envelope");
assert_eq!(decoded.payload_digest, expected);
decoded
.validate_payload_digest()
.expect("round-trip digest");
let Request::UpdateProviderEnvironment { provider_env, .. } = &mut decoded.request
else {
panic!("decoded the wrong request variant");
};
provider_env.insert("A".to_string(), "changed".to_string());
assert!(matches!(
decoded.validate_payload_digest(),
Err(FrameError::PayloadDigestMismatch)
));
}
let mut envelope = RequestEnvelope::new(build(std::collections::HashMap::new()))
.expect("request envelope");
envelope.request = Request::Terminate {
process_id: "different".to_string(),
};
assert!(matches!(
envelope.validate_payload_digest(),
Err(FrameError::PayloadDigestMismatch)
));
let mut request = build(std::collections::HashMap::new());
let Request::UpdateProviderEnvironment { provider_files, .. } = &mut request else {
unreachable!();
};
provider_files.insert(
"/run/openshell/providers/acme/client.toml".to_string(),
"version = 1".to_string(),
);
let mut envelope = RequestEnvelope::new(request).expect("file-bearing request envelope");
let Request::UpdateProviderEnvironment { provider_files, .. } = &mut envelope.request
else {
unreachable!();
};
provider_files.insert(
"/run/openshell/providers/acme/client.toml".to_string(),
"version = 2".to_string(),
);
assert!(matches!(
envelope.validate_payload_digest(),
Err(FrameError::PayloadDigestMismatch)
));
}
#[test]
fn start_agent_with_large_ca_bundle_fits_in_frame_limit() {
let request = RequestEnvelope::new(Request::StartAgent {
provider_files: std::collections::HashMap::new(),
sandbox_id: "sandbox-1".to_string(),
spec: AgentSpecWire {
program: "/bin/true".to_string(),
args: Vec::new(),
workdir: None,
timeout_secs: 5,
interactive: false,
},
ca_cert: Some("A".repeat(16 * 1024)),
ca_bundle: Some("B".repeat(400 * 1024)),
provider_env_revision: 0,
provider_env: std::collections::HashMap::new(),
})
.expect("request envelope");
let frame = encode_frame(&request).expect("large CA bundle must fit in frame limit");
let decoded: RequestEnvelope = decode_frame(&frame).expect("round-trip");
assert_eq!(decoded, request);
}
#[test]
fn rejects_declared_oversize() {
let oversized = u32::try_from(MAX_CONTROL_FRAME_BYTES + 1).expect("test size fits u32");
let mut frame = Vec::from(oversized.to_be_bytes());
frame.extend_from_slice(b"{}");
assert!(matches!(
decode_frame::<RequestEnvelope>(&frame),
Err(FrameError::TooLarge(_))
));
}
#[test]
fn resource_claims_reject_empty_or_ambiguous_identities() {
assert!(
validate_resource_claims(&std::collections::BTreeMap::from([(
"kubernetes.pod_uid".to_string(),
String::new()
),]))
.is_err()
);
assert!(
validate_resource_claims(&std::collections::BTreeMap::from([(
"kubernetes.pod uid".to_string(),
"uid-1".to_string()
),]))
.is_err()
);
validate_resource_claims(&std::collections::BTreeMap::from([(
"kubernetes.pod_uid".to_string(),
"uid-1".to_string(),
)]))
.expect("opaque resource identity should be valid");
}
#[test]
fn sandbox_tls_identity_is_unique_per_session_and_redacted() {
let first_session = SandboxSessionId::new();
let second_session = SandboxSessionId::new();
let first = generate_sandbox_tls_material(first_session).expect("first TLS material");
let second = generate_sandbox_tls_material(second_session).expect("second TLS material");
assert_eq!(
first.server_name,
format!("sandbox.{first_session}.openshell.internal")
);
assert_ne!(first.server_name, second.server_name);
assert_ne!(first.trust_anchor_pem, second.trust_anchor_pem);
assert!(first.certificate_chain_pem.contains("BEGIN CERTIFICATE"));
assert!(first.private_key_pem.contains("BEGIN PRIVATE KEY"));
let debug = format!("{first:?}");
assert!(!debug.contains(&first.private_key_pem));
assert!(!debug.contains(&first.certificate_chain_pem));
}
#[test]
fn sandbox_transport_does_not_embed_authentication_material() {
let transport = SandboxTransport::Tcp {
authority: "sandbox.default.svc.cluster.local".to_string(),
addresses: vec!["192.0.2.10:8443".parse().expect("test address")],
};
let encoded = serde_json::to_vec(&transport).expect("encode transport");
let decoded: SandboxTransport = serde_json::from_slice(&encoded).expect("decode transport");
assert_eq!(decoded, transport);
}
#[test]
fn exec_wire_accepts_legacy_direct_exec_without_shell_intent() {
let wire: ExecSpecWire = serde_json::from_str(
r#"{"program":"/bin/true","args":[],"env":[],"workdir":null,"pty":false}"#,
)
.expect("decode legacy exec spec");
assert_eq!(wire.shell, None);
assert_eq!(wire.runtime_helper, None);
}
#[test]
fn exec_wire_preserves_trusted_runtime_helper_intent() {
let spec = ExecSpec {
program: String::new(),
args: Vec::new(),
shell: None,
runtime_helper: Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp),
env: Vec::new(),
workdir: None,
pty: false,
};
let decoded = ExecSpec::from(ExecSpecWire::from(spec));
assert_eq!(
decoded.runtime_helper,
Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp)
);
}
}