From c94bc458363533f3b0aa35d65950b16b10bbb35b Mon Sep 17 00:00:00 2001 From: Free Wortley Date: Sat, 17 Oct 2026 01:35:31 +0000 Subject: [PATCH 1/3] feat(middleware): authorize supervisor bindings at registration Let operators require and limit the operation and phase pairs that an external middleware may expose. Validate the same binding policy at gateway startup and when a supervisor discovers the service, and keep only authorized bindings in the registry used for dispatch. Preserve dynamic discovery and the existing wire configuration when no binding policy is set. Document that older supervisors must be upgraded before relying on binding restrictions. Signed-off-by: Free Wortley --- Cargo.lock | 1 + crates/openshell-core/build.rs | 4 + crates/openshell-core/src/config.rs | 13 +- crates/openshell-core/src/lib.rs | 10 +- crates/openshell-server/src/config_file.rs | 70 +++- crates/openshell-server/src/grpc/policy.rs | 25 +- crates/openshell-server/src/storage_proto.rs | 6 +- .../Cargo.toml | 1 + .../src/binding_policy.rs | 134 +++++++ .../src/lib.rs | 148 ++++---- .../src/response.rs | 120 ++++++- .../src/l7/relay.rs | 4 +- .../src/l7/websocket.rs | 16 +- .../supervisor-middleware/configure.mdx | 8 + docs/how-it-works/gateways/configuration.mdx | 21 +- proto/sandbox.proto | 28 ++ sdk/go/proto/sandboxv1/sandbox.pb.go | 335 +++++++++++++----- 17 files changed, 739 insertions(+), 205 deletions(-) create mode 100644 crates/openshell-supervisor-middleware/src/binding_policy.rs diff --git a/Cargo.lock b/Cargo.lock index e152fdc96d..475bcc6529 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5054,6 +5054,7 @@ dependencies = [ "tokio", "tokio-stream", "tonic", + "tracing", ] [[package]] diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index fdd1797e04..f85f7bb403 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -74,6 +74,10 @@ fn main() -> Result<(), Box> { // Configure tonic/prost protobuf code generation. tonic_prost_build::configure() .btree_map(".openshell.sandbox.protocol.v1") + .type_attribute( + ".openshell.sandbox.v1.SupervisorMiddlewareBinding", + r#"#[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")]"#, + ) .build_server(true) .build_client(true) .include_file("openshell.rs") diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 7f5d4d1ec0..26eed34e66 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -541,21 +541,22 @@ impl GatewayInterceptorConfig { } } -/// Operator policy for authorizing interceptor manifest bindings. +/// Operator policy for authorizing bindings advertised by an extension. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum GatewayInterceptorBindingPolicy { - /// Preserve manifest-controlled binding discovery. Configured bindings - /// may narrow or disable manifest declarations. + /// Preserve manifest-controlled binding discovery. #[default] Dynamic, - /// Enable only configured RPC selectors and phases. Extra manifest - /// declarations are ignored. + /// Require and enable only configured bindings. Ignore extra declarations. Allowlist, - /// Require configured and manifest RPC selectors and phases to match. + /// Require configured and manifest bindings to match exactly. Exact, } +/// The binding policy shared by gateway interceptors and supervisor middleware. +pub use GatewayInterceptorBindingPolicy as ExtensionBindingPolicy; + /// One configured source in the gateway's effective provider-profile catalog. /// /// Deserialization is hand-written so that the removed `builtin` source is diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 9e70b309c2..b40607e873 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -68,11 +68,11 @@ pub mod transport_errors; pub mod upload; pub use config::{ - AppArmorProfile, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, - GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, - GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, - ImagePullPolicy, MtlsAuthConfig, OidcConfig, PolicyValidationFailureMode, TlsConfig, - UpstreamProxyConfig, + AppArmorProfile, Config, ExtensionBindingPolicy, GatewayAuthConfig, + GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, + GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, GatewayJwtConfig, + GatewayProviderProfileSourceConfig, ImagePullPolicy, MtlsAuthConfig, OidcConfig, + PolicyValidationFailureMode, TlsConfig, UpstreamProxyConfig, }; pub use dynamic_string_allowlist::DynamicStringAllowlist; pub use error::{ComputeDriverError, Error, Result}; diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index cba890bff2..35fe121e82 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -26,9 +26,11 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use base64::Engine as _; -use openshell_core::proto::SupervisorMiddlewareService; +use openshell_core::proto::{ + SupervisorMiddlewareBinding, SupervisorMiddlewareBindingPolicy, SupervisorMiddlewareService, +}; use openshell_core::{ - GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, + ExtensionBindingPolicy, GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, }; use serde::{Deserialize, Serialize}; @@ -502,6 +504,12 @@ pub struct MiddlewareServiceFileConfig { /// Default RPC timeout using an integer with an `ms` or `s` suffix. #[serde(default)] pub timeout: Option, + /// Which advertised operation/phase pairs the operator authorizes. + #[serde(default)] + pub binding_policy: ExtensionBindingPolicy, + /// Required operation/phase pairs for an allowlist or exact policy. + #[serde(default)] + pub bindings: Vec, } impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { @@ -521,9 +529,25 @@ impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { None => Vec::new(), }; + let binding_policy = match config.binding_policy { + // Preserve the wire config and revision of registrations using the default. + ExtensionBindingPolicy::Dynamic => SupervisorMiddlewareBindingPolicy::Unspecified, + ExtensionBindingPolicy::Allowlist => SupervisorMiddlewareBindingPolicy::Allowlist, + ExtensionBindingPolicy::Exact => SupervisorMiddlewareBindingPolicy::Exact, + }; + // Binding order has no meaning and must not change the configuration revision. + // Keep duplicates so registration validation can report them. + let mut bindings: Vec<_> = config + .bindings + .iter() + .map(|binding| *binding as i32) + .collect(); + bindings.sort_unstable(); Ok(Self { name: config.name.clone(), grpc_endpoint: config.grpc_endpoint.clone(), + binding_policy: binding_policy.into(), + bindings, max_payload_bytes: config.max_payload_bytes, request_timeout: config .timeout @@ -1468,6 +1492,8 @@ timeout = "2s" allow_insecure_transport: false, max_payload_bytes: 262_144, timeout: Some("2s".into()), + binding_policy: ExtensionBindingPolicy::Dynamic, + bindings: vec![], }] ); let registration = @@ -1485,6 +1511,40 @@ timeout = "2s" ); } + #[test] + fn supervisor_middleware_binding_policy_reaches_wire_configuration() { + use SupervisorMiddlewareBinding::{HttpRequestPreCredentials, HttpResponsePreReturn}; + use SupervisorMiddlewareBindingPolicy::{Allowlist, Unspecified}; + + let toml = r#" +name = "guard" +grpc_endpoint = "https://guard.example:50051" +max_payload_bytes = 1024 +"#; + let parse = |text: &str| { + let config: MiddlewareServiceFileConfig = ::toml::from_str(text).unwrap(); + SupervisorMiddlewareService::try_from(&config).unwrap() + }; + let default = parse(toml); + assert_eq!(default.binding_policy, Unspecified as i32); + assert!(default.bindings.is_empty()); + + let registration = parse(&format!( + r#"{toml} +binding_policy = "allowlist" +bindings = ["http_response_pre_return", "http_request_pre_credentials"] +"# + )); + assert_eq!(registration.binding_policy, Allowlist as i32); + assert_eq!( + registration.bindings, + [ + HttpRequestPreCredentials as i32, + HttpResponsePreReturn as i32 + ] + ); + } + #[test] fn middleware_registration_defaults_audience_to_name() { let mut config = MiddlewareServiceFileConfig { @@ -1495,6 +1555,8 @@ timeout = "2s" allow_insecure_transport: false, max_payload_bytes: 262_144, timeout: None, + binding_policy: ExtensionBindingPolicy::Dynamic, + bindings: vec![], }; let registration = SupervisorMiddlewareService::try_from(&config).unwrap(); @@ -1527,6 +1589,8 @@ timeout = "2s" allow_insecure_transport: false, max_payload_bytes: 262_144, timeout: None, + binding_policy: ExtensionBindingPolicy::Dynamic, + bindings: vec![], }; let error = SupervisorMiddlewareService::try_from(&config) @@ -1559,6 +1623,8 @@ timeout = "2s" allow_insecure_transport: false, max_payload_bytes: 262_144, timeout: None, + binding_policy: ExtensionBindingPolicy::Dynamic, + bindings: vec![], }; let error = SupervisorMiddlewareService::try_from(&config) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 4a6f32f1aa..42b2f05783 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -22994,19 +22994,34 @@ mod tests { #[test] fn config_revision_changes_when_supervisor_middleware_services_change() { + use openshell_core::proto::{ + SupervisorMiddlewareBinding, SupervisorMiddlewareBindingPolicy, + }; + let policy = ProtoSandboxPolicy::default(); let settings = HashMap::new(); - let service = openshell_core::proto::SupervisorMiddlewareService { + let mut service = openshell_core::proto::SupervisorMiddlewareService { name: "local-guard".into(), grpc_endpoint: "http://127.0.0.1:50051".into(), max_payload_bytes: 1024, ..Default::default() }; - let without = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]); - let with = - compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[service]); - assert_ne!(without, with); + let revision = |services: &[openshell_core::proto::SupervisorMiddlewareService]| { + compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, services) + }; + let without = revision(&[]); + let dynamic = revision(std::slice::from_ref(&service)); + assert_ne!(without, dynamic); + + service.binding_policy = SupervisorMiddlewareBindingPolicy::Allowlist.into(); + service.bindings = vec![SupervisorMiddlewareBinding::HttpRequestPreCredentials.into()]; + let request = revision(std::slice::from_ref(&service)); + assert_ne!(dynamic, request); + service + .bindings + .push(SupervisorMiddlewareBinding::HttpResponsePreReturn.into()); + assert_ne!(request, revision(&[service])); } #[test] diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 808c47cab3..37bbcdbc6d 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -156,8 +156,10 @@ mod tests { // Sandbox-wide grant reporting adds TokenGrantFailureObservation to both // closures and TokenGrantStatus to public reports. Older rows have no grant // record; absent report extensions retain compatibility without a rewrite. + // Supervisor middleware binding policy and selectors are public-only. An + // absent policy preserves dynamic discovery; older supervisors ignore both fields. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "a91e2244a2c1a8e369efe85bd0e4f2769f0f29f6263137280fe38e8a8da3cc6e"; + "5ae09053b7d6a114971edc32770918d63ae5848bb9670135b67d7891dec1ccd5"; const DURABLE_SCHEMA_SHA256: &str = "d4dc7abdb91b919db1f7be0d4652e30cbfda86f3b2ed9b59d5de1f76a2bf691d"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = @@ -679,7 +681,7 @@ mod tests { overlap_hash.as_str(), ), ( - (328, 29), + (328, 31), (95, 22), (83, 22), PUBLIC_RPC_SCHEMA_SHA256, diff --git a/crates/openshell-supervisor-middleware/Cargo.toml b/crates/openshell-supervisor-middleware/Cargo.toml index 453520c3eb..104f1f41e6 100644 --- a/crates/openshell-supervisor-middleware/Cargo.toml +++ b/crates/openshell-supervisor-middleware/Cargo.toml @@ -21,6 +21,7 @@ prost-types = { workspace = true } tokio = { workspace = true } tokio-stream = { workspace = true } tonic = { workspace = true, features = ["channel", "server", "tls-native-roots"] } +tracing = { workspace = true } [dev-dependencies] openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } diff --git a/crates/openshell-supervisor-middleware/src/binding_policy.rs b/crates/openshell-supervisor-middleware/src/binding_policy.rs new file mode 100644 index 0000000000..f95d2732da --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/binding_policy.rs @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; + +use miette::{Result, miette}; +use openshell_core::proto::{ + MiddlewareBinding, SupervisorMiddlewareBinding as Binding, + SupervisorMiddlewareBindingPolicy as Policy, SupervisorMiddlewareService, +}; + +/// A validated operator policy. The gateway sends the same policy to the +/// supervisor, which authorizes its own Describe rather than trusting the +/// service to advertise the same bindings to both callers. +pub enum BindingPolicy { + Dynamic, + Allowlist(BTreeSet), + Exact(BTreeSet), +} + +impl TryFrom<&SupervisorMiddlewareService> for BindingPolicy { + type Error = miette::Report; + + fn try_from(service: &SupervisorMiddlewareService) -> Result { + let name = &service.name; + let policy = Policy::try_from(service.binding_policy) + .map_err(|_| miette!("middleware '{name}' has unknown binding_policy"))?; + let mut bindings = BTreeSet::new(); + for &value in &service.bindings { + let binding = Binding::try_from(value) + .ok() + .filter(|binding| *binding != Binding::Unspecified) + .ok_or_else(|| miette!("middleware '{name}' has invalid binding {value}"))?; + if !bindings.insert(binding) { + return Err(miette!("middleware '{name}' repeats binding {binding:?}")); + } + } + match policy { + // Gateways that predate binding policies omit both fields. + Policy::Unspecified | Policy::Dynamic if bindings.is_empty() => Ok(Self::Dynamic), + Policy::Unspecified | Policy::Dynamic => Err(miette!( + "middleware '{name}' with bindings must set binding_policy to allowlist or exact" + )), + Policy::Allowlist | Policy::Exact if bindings.is_empty() => Err(miette!( + "middleware '{name}' with {policy:?} binding_policy must configure bindings" + )), + Policy::Allowlist => Ok(Self::Allowlist(bindings)), + Policy::Exact => Ok(Self::Exact(bindings)), + } + } +} + +impl BindingPolicy { + pub fn authorize( + &self, + source: &str, + advertised: &mut BTreeMap, + ) -> Result<()> { + let (Self::Allowlist(required) | Self::Exact(required)) = self else { + tracing::warn!( + "{source} uses dynamic binding discovery; the service controls its bindings" + ); + return Ok(()); + }; + for binding in required { + if !advertised.contains_key(binding) { + return Err(miette!( + "{source} does not advertise required binding {binding:?}" + )); + } + } + if let Self::Exact(_) = self + && let Some(extra) = advertised + .keys() + .find(|binding| !required.contains(binding)) + { + return Err(miette!( + "{source} advertises binding {extra:?} outside its exact policy" + )); + } + advertised.retain(|binding, _| { + let authorized = required.contains(binding); + if !authorized { + tracing::info!("{source} binding {binding:?} is not in the allowlist, ignoring"); + } + authorized + }); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_binding_policies_are_not_treated_as_dynamic() { + let request = Binding::HttpRequestPreCredentials as i32; + for (policy, bindings) in [ + (1234, vec![]), + (Policy::Unspecified as i32, vec![request]), + (Policy::Dynamic as i32, vec![request]), + (Policy::Allowlist as i32, vec![]), + (Policy::Exact as i32, vec![]), + (Policy::Exact as i32, vec![0]), + (Policy::Allowlist as i32, vec![1234]), + (Policy::Allowlist as i32, vec![request, request]), + ] { + let service = SupervisorMiddlewareService { + binding_policy: policy, + bindings, + ..Default::default() + }; + assert!(BindingPolicy::try_from(&service).is_err(), "{service:?}"); + } + } + + #[test] + fn exact_requires_the_same_set_not_just_a_subset() { + let request = Binding::HttpRequestPreCredentials; + let response = Binding::HttpResponsePreReturn; + let policy = BindingPolicy::Exact(BTreeSet::from([request])); + let matching = BTreeMap::from([(request, MiddlewareBinding::default())]); + policy.authorize("service", &mut matching.clone()).unwrap(); + + let mut extra = matching; + extra.insert(response, MiddlewareBinding::default()); + let error = policy.authorize("service", &mut extra).unwrap_err(); + assert!( + error.to_string().contains("outside its exact policy"), + "{error}" + ); + } +} diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index b621f8ad5c..63e71c869b 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -3,6 +3,7 @@ //! Supervisor middleware registration and chain execution. +mod binding_policy; pub mod headers; mod remote; mod response; @@ -37,16 +38,19 @@ use openshell_core::proto::middleware::v1::supervisor_middleware_server::Supervi use openshell_core::proto::{ Decision, Finding, HeaderMutation, HttpHeader, HttpRequestEvaluation, HttpRequestTarget, MiddlewareBinding, MiddlewareDescribeRequest, MiddlewareManifest, NetworkMiddlewareConfig, - RequestContext, SandboxPolicy, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, - SupervisorMiddlewareService, ValidateConfigRequest, ValidateConfigResponse, + RequestContext, SandboxPolicy, SupervisorMiddlewareBinding, SupervisorMiddlewareOperation, + SupervisorMiddlewarePhase, SupervisorMiddlewareService, ValidateConfigRequest, + ValidateConfigResponse, }; use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore}; use tonic::{Request, Response as TonicResponse, Status as TonicStatus}; +use binding_policy::BindingPolicy; pub use openshell_core::middleware::{ HttpRequestView, HttpResponseResultStream, InProcessMiddleware, SupervisorMiddlewareEndpoint, WebSocketResponseStream, }; + pub type MiddlewareService = dyn SupervisorMiddleware; @@ -776,7 +780,8 @@ impl Default for MiddlewareRegistry { /// Validate one external middleware registration without opening its transport. pub fn validate_registration_config(registration: &SupervisorMiddlewareService) -> Result<()> { - validate_registration(registration).map(|_| ()) + validate_registration(registration)?; + BindingPolicy::try_from(registration).map(|_| ()) } fn validate_registration(registration: &SupervisorMiddlewareService) -> Result { @@ -860,14 +865,10 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result Result { +fn supported_binding( + source: &str, + binding: &MiddlewareBinding, +) -> Result { match ( SupervisorMiddlewareOperation::try_from(binding.operation).ok(), SupervisorMiddlewarePhase::try_from(binding.phase).ok(), @@ -875,15 +876,15 @@ fn supported_binding(source: &str, binding: &MiddlewareBinding) -> Result Ok(SupportedBinding::HttpPreCredentials), + ) => Ok(SupervisorMiddlewareBinding::HttpRequestPreCredentials), ( Some(SupervisorMiddlewareOperation::HttpResponse), Some(SupervisorMiddlewarePhase::PreReturn), - ) => Ok(SupportedBinding::HttpResponsePreReturn), + ) => Ok(SupervisorMiddlewareBinding::HttpResponsePreReturn), ( Some(SupervisorMiddlewareOperation::WebsocketMessage), Some(SupervisorMiddlewarePhase::PreCredentials), - ) => Ok(SupportedBinding::WebSocketPreCredentials), + ) => Ok(SupervisorMiddlewareBinding::WebsocketMessagePreCredentials), ( Some(SupervisorMiddlewareOperation::WebsocketMessage), Some(SupervisorMiddlewarePhase::PreReturn), @@ -899,38 +900,26 @@ fn supported_binding(source: &str, binding: &MiddlewareBinding) -> Result, -) -> Result<()> { +) -> Result> { if manifest.bindings.is_empty() { return Err(miette!("{source} describes no bindings")); } - let mut described_pairs = HashSet::with_capacity(manifest.bindings.len()); + let mut bindings = BTreeMap::new(); for binding in &manifest.bindings { - supported_binding(source, binding)?; - if !described_pairs.insert((binding.operation, binding.phase)) { + let selector = supported_binding(source, binding)?; + if bindings.insert(selector, *binding).is_some() { return Err(miette!( "{source} describes a duplicate middleware operation/phase pair" )); } - let advertised = validate_payload_limit(source, binding)?; + validate_payload_limit(source, binding)?; if binding.request_timeout.is_some() { middleware_proto_timeout_or_default(binding.request_timeout.as_ref()) .map_err(|reason| miette!("{source} has invalid timeout for binding: {reason}"))?; } - if operator_max_payload_bytes.is_some_and(|limit| limit > advertised) { - return Err(miette!( - "{source} max_payload_bytes ({}) exceeds the binding capability ({advertised})", - operator_max_payload_bytes.expect("operator limit checked above") - )); - } - if operator_max_payload_bytes == Some(0) { - return Err(miette!( - "{source} must configure max_payload_bytes for every payload-bearing binding" - )); - } } - Ok(()) + Ok(bindings) } fn middleware_proto_timeout_or_default( @@ -951,23 +940,38 @@ fn middleware_proto_timeout_or_default( Ok(timeout) } -fn validate_external_manifest( +/// The cached manifest is the only source of bindings for validation and dispatch. +/// Store only authorized bindings, both in the gateway and after the supervisor's own Describe. +fn authorize_external_manifest( registration: &SupervisorMiddlewareService, manifest: &MiddlewareManifest, - operator_max_payload_bytes: usize, authenticated: bool, -) -> Result<()> { - validate_manifest_bindings( - &format!("external middleware registration '{}'", registration.name), - manifest, - Some(operator_max_payload_bytes), - )?; + policy: &BindingPolicy, +) -> Result> { + let source = format!("external middleware registration '{}'", registration.name); + let mut bindings = validate_manifest_bindings(&source, manifest)?; + policy.authorize(&source, &mut bindings)?; + let operator_max_payload_bytes = registration.max_payload_bytes; + for binding in bindings.values() { + let advertised = binding.max_payload_bytes; + if operator_max_payload_bytes > advertised { + return Err(miette!( + "{source} max_payload_bytes ({operator_max_payload_bytes}) exceeds the binding capability ({advertised})" + )); + } + if operator_max_payload_bytes == 0 { + return Err(miette!( + "{source} must configure max_payload_bytes for every payload-bearing binding" + )); + } + } validate_expected_audience( ®istration.name, ®istration.audience, &manifest.expected_audience, authenticated && !registration.allow_insecure_transport, - ) + )?; + Ok(bindings.into_values().collect()) } /// After authenticated Describe succeeds, reject a registration whose @@ -1161,7 +1165,7 @@ impl MiddlewareRegistry { manifest.name )); } - validate_manifest_bindings(&source, &manifest, None)?; + validate_manifest_bindings(&source, &manifest)?; negotiated_extensions.push( negotiate( ExtensionFamily::SupervisorMiddleware, @@ -1188,6 +1192,7 @@ impl MiddlewareRegistry { for registration in registrations { let operator_timeout = validate_registration(®istration)?; + let binding_policy = BindingPolicy::try_from(®istration)?; if !middleware_names.insert(registration.name.clone()) { return Err(miette!( "duplicate supervisor middleware registration name '{}'", @@ -1227,7 +1232,7 @@ impl MiddlewareRegistry { ) .await?, ); - let manifest = call_with_timeout(operator_timeout, "Describe", service.describe()) + let mut manifest = call_with_timeout(operator_timeout, "Describe", service.describe()) .await .map(tonic::Response::into_inner) .map_err(|error| { @@ -1237,11 +1242,11 @@ impl MiddlewareRegistry { safe_reason(&error.to_string()) ) })?; - validate_external_manifest( + manifest.bindings = authorize_external_manifest( ®istration, &manifest, - operator_max_payload_bytes, authenticated, + &binding_policy, )?; negotiated_extensions.push( negotiate( @@ -3556,7 +3561,7 @@ mod tests { .next() .expect("built-in middleware service"); let builtin_manifest = builtin_service.describe().await; - validate_manifest_bindings("test built-in service", &builtin_manifest, None) + validate_manifest_bindings("test built-in service", &builtin_manifest) .expect("valid built-in manifest"); let builtin_name = builtin_manifest.name.clone(); let builtin_manifest_cell = OnceCell::new(); @@ -3564,7 +3569,7 @@ mod tests { .set(builtin_manifest) .expect("built-in manifest cache"); - let manifest = service + let mut manifest = service .describe(Request::new(MiddlewareDescribeRequest { gateway: Some(gateway_metadata(ExtensionFamily::SupervisorMiddleware)), })) @@ -3573,8 +3578,13 @@ mod tests { .into_inner(); let operator_max_payload_bytes = usize::try_from(registration.max_payload_bytes).unwrap(); let operator_timeout = validate_registration(®istration).expect("valid registration"); - validate_external_manifest(®istration, &manifest, operator_max_payload_bytes, false) - .expect("valid external manifest"); + manifest.bindings = authorize_external_manifest( + ®istration, + &manifest, + false, + &BindingPolicy::try_from(®istration).unwrap(), + ) + .expect("valid external manifest"); let manifest_cell = OnceCell::new(); manifest_cell.set(manifest).expect("manifest cache"); let registration_name = registration.name.clone(); @@ -3804,8 +3814,9 @@ mod tests { [], )), }; - let error = validate_external_manifest(®istration, &manifest, 4097, false) - .expect_err("operator limit must fit capability"); + let error = + authorize_external_manifest(®istration, &manifest, false, &BindingPolicy::Dynamic) + .expect_err("operator limit must fit capability"); assert!(error.to_string().contains("exceeds")); } @@ -3837,8 +3848,9 @@ mod tests { [], )), }; - let error = validate_external_manifest(®istration, &manifest, 4096, false) - .expect_err("extreme advertised payload limit must be rejected"); + let error = + authorize_external_manifest(®istration, &manifest, false, &BindingPolicy::Dynamic) + .expect_err("extreme advertised payload limit must be rejected"); assert!(error.to_string().contains("platform maximum")); } @@ -3864,8 +3876,9 @@ mod tests { )), }; - let error = validate_external_manifest(®istration, &manifest, 4096, false) - .expect_err("one service cannot advertise two bindings for the same pair"); + let error = + authorize_external_manifest(®istration, &manifest, false, &BindingPolicy::Dynamic) + .expect_err("one service cannot advertise two bindings for the same pair"); assert!( error .to_string() @@ -3897,7 +3910,7 @@ mod tests { )), }; - validate_external_manifest(®istration, &manifest, 4096, false) + authorize_external_manifest(®istration, &manifest, false, &BindingPolicy::Dynamic) .expect("HTTP response pre-return binding is supported"); } @@ -3921,11 +3934,11 @@ mod tests { [], )), }; - validate_manifest_bindings("test WebSocket service", &manifest, None) + validate_manifest_bindings("test WebSocket service", &manifest) .expect("forward WebSocket binding is supported"); manifest.bindings = vec![binding(SupervisorMiddlewarePhase::PreReturn)]; - let error = validate_manifest_bindings("test WebSocket service", &manifest, None) + let error = validate_manifest_bindings("test WebSocket service", &manifest) .expect_err("return-path WebSocket binding is not yet supported"); assert!(error.to_string().contains("not yet supported")); } @@ -3951,8 +3964,9 @@ mod tests { )), }; - let error = validate_external_manifest(®istration, &manifest, 0, false) - .expect_err("WebSocket bindings require an operator payload ceiling"); + let error = + authorize_external_manifest(®istration, &manifest, false, &BindingPolicy::Dynamic) + .expect_err("WebSocket bindings require an operator payload ceiling"); assert!( error .to_string() @@ -3981,8 +3995,9 @@ mod tests { )), }; - let error = validate_external_manifest(®istration, &manifest, 4097, false) - .expect_err("operator payload limit must fit WebSocket capability"); + let error = + authorize_external_manifest(®istration, &manifest, false, &BindingPolicy::Dynamic) + .expect_err("operator payload limit must fit WebSocket capability"); assert!(error.to_string().contains("exceeds")); } @@ -4060,8 +4075,13 @@ mod tests { [], )), }; - let error = validate_external_manifest(®istration, &manifest, 4096, false) - .expect_err("out-of-bounds binding timeout must be rejected"); + let error = authorize_external_manifest( + ®istration, + &manifest, + false, + &BindingPolicy::Dynamic, + ) + .expect_err("out-of-bounds binding timeout must be rejected"); assert!(error.to_string().contains("invalid timeout")); } } diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 6fe6d6c427..57e1217fcf 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -1236,6 +1236,7 @@ mod tests { #[derive(Clone)] struct RemoteResponseService { + advertise_request: tokio::sync::watch::Receiver, session_end_tx: Option>, } @@ -1249,9 +1250,17 @@ mod tests { &self, _request: tonic::Request, ) -> Result, tonic::Status> { - Ok(tonic::Response::new(response_manifest( - "test/remote-response", - ))) + use openshell_core::proto::{SupervisorMiddlewareOperation, SupervisorMiddlewarePhase}; + + let mut manifest = response_manifest("test/remote-response"); + if *self.advertise_request.borrow() { + manifest.bindings.push(MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest.into(), + phase: SupervisorMiddlewarePhase::PreCredentials.into(), + ..manifest.bindings[0] + }); + } + Ok(tonic::Response::new(manifest)) } async fn validate_config( @@ -2553,8 +2562,13 @@ mod tests { } } - #[tokio::test] - async fn remote_service_executes_through_http_response_pre_return_rpc() { + async fn serve_remote_response_service( + service: RemoteResponseService, + ) -> ( + String, + tokio::sync::oneshot::Sender<()>, + tokio::task::JoinHandle>, + ) { use openshell_core::proto::middleware::v1::http_response_pre_return_server::HttpResponsePreReturnServer; use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddlewareServer; @@ -2563,10 +2577,6 @@ mod tests { .expect("bind response middleware"); let address = listener.local_addr().expect("response middleware address"); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let (session_end_tx, mut session_end_rx) = mpsc::unbounded_channel(); - let service = RemoteResponseService { - session_end_tx: Some(session_end_tx), - }; let server = tonic::transport::Server::builder() .add_service(SupervisorMiddlewareServer::new(service.clone())) .add_service(HttpResponsePreReturnServer::new(service)) @@ -2574,11 +2584,101 @@ mod tests { let _ = shutdown_rx.await; }); let server_task = tokio::spawn(server); + (format!("http://{address}"), shutdown_tx, server_task) + } + + #[tokio::test] + async fn supervisor_rechecks_required_binding_after_gateway_accepts_service() { + use crate::MiddlewareRegistry; + use openshell_core::proto::{ + NetworkMiddlewareConfig, SandboxPolicy, SupervisorMiddlewareBinding, + SupervisorMiddlewareBindingPolicy, SupervisorMiddlewareService, + }; + + // The gateway sees a service offering both request and response hooks. + let (request, advertise_request) = tokio::sync::watch::channel(true); + let (grpc_endpoint, shutdown, server) = + serve_remote_response_service(RemoteResponseService { + advertise_request, + session_end_tx: None, + }) + .await; + let name = "remote-response"; + let registration = SupervisorMiddlewareService { + name: name.into(), + grpc_endpoint, + max_payload_bytes: 4096, + binding_policy: SupervisorMiddlewareBindingPolicy::Allowlist.into(), + bindings: vec![SupervisorMiddlewareBinding::HttpRequestPreCredentials.into()], + ..Default::default() + }; + let gateway = MiddlewareRegistry::connect_services(vec![], vec![registration.clone()]) + .await + .expect("gateway accepts required request binding"); + let entry = ChainEntry { + implementation: name.into(), + ..entry(OnError::FailClosed) + }; + let runner = ChainRunner::from_registry(gateway.clone()); + assert_eq!( + runner + .describe_chain(std::slice::from_ref(&entry)) + .await + .unwrap() + .len(), + 1 + ); + assert!( + runner + .describe_http_response_chain(&[entry]) + .await + .unwrap() + .is_empty() + ); + + // Deliver the gateway's actual registration to a supervisor. This time + // Describe offers only a response hook, so no registry can be installed. + let policy = SandboxPolicy { + network_middlewares: std::collections::HashMap::from([( + "guard".into(), + NetworkMiddlewareConfig { + middleware: name.into(), + ..Default::default() + }, + )]), + ..Default::default() + }; + let delivered = gateway.required_services(Some(&policy)); + assert_eq!(delivered, [registration]); + request.send(false).unwrap(); + let error = MiddlewareRegistry::connect_services(vec![], delivered) + .await + .expect_err("supervisor must reject a missing required binding"); + assert!( + error + .to_string() + .contains("does not advertise required binding HttpRequestPreCredentials"), + "{error}" + ); + + shutdown.send(()).unwrap(); + server.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn remote_service_executes_through_http_response_pre_return_rpc() { + let (session_end_tx, mut session_end_rx) = mpsc::unbounded_channel(); + let service = RemoteResponseService { + session_end_tx: Some(session_end_tx), + advertise_request: tokio::sync::watch::channel(false).1, + }; + let (grpc_endpoint, shutdown_tx, server_task) = + serve_remote_response_service(service).await; let registry = super::super::MiddlewareRegistry::connect_services( Vec::new(), vec![openshell_core::proto::SupervisorMiddlewareService { name: "remote-response".into(), - grpc_endpoint: format!("http://{address}"), + grpc_endpoint, max_payload_bytes: 4096, allow_insecure_transport: true, ..Default::default() diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 9603f65a02..41dbeafe4d 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -4766,9 +4766,7 @@ network_policies: seconds: 2, nanos: 0, }), - tls_ca_cert_pem: Vec::new(), - audience: String::new(), - allow_insecure_transport: false, + ..Default::default() }], ) .await diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index f2ea281a81..772953e302 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -3772,9 +3772,7 @@ network_policies: seconds: 2, nanos: 0, }), - tls_ca_cert_pem: Vec::new(), - audience: String::new(), - allow_insecure_transport: false, + ..Default::default() }], ) .await @@ -3847,9 +3845,7 @@ network_policies: seconds: 2, nanos: 0, }), - tls_ca_cert_pem: Vec::new(), - audience: String::new(), - allow_insecure_transport: false, + ..Default::default() }], ) .await @@ -4807,9 +4803,7 @@ network_policies: seconds: 2, nanos: 0, }), - tls_ca_cert_pem: Vec::new(), - audience: String::new(), - allow_insecure_transport: false, + ..Default::default() }], ) .await @@ -4960,9 +4954,7 @@ network_policies: seconds: 2, nanos: 0, }), - tls_ca_cert_pem: Vec::new(), - audience: String::new(), - allow_insecure_transport: false, + ..Default::default() }], ) .await diff --git a/docs/extensibility/supervisor-middleware/configure.mdx b/docs/extensibility/supervisor-middleware/configure.mdx index 7ea8183af3..de2f22d76b 100644 --- a/docs/extensibility/supervisor-middleware/configure.mdx +++ b/docs/extensibility/supervisor-middleware/configure.mdx @@ -44,6 +44,14 @@ timeout = "500ms" Largest request body, response body, or WebSocket message the service inspects. Must be at most 4 MiB and no larger than the service advertises. + + Which advertised operation and phase pairs OpenShell uses. To require a request hook, set `binding_policy = "allowlist"` and `bindings = ["http_request_pre_credentials"]`. Both the gateway and the supervisor reject a response-only service with this registration. Keep `on_error: fail_closed` on the policy attachment to block matching requests if the middleware is unavailable. + + + + Bindings required by `allowlist` or `exact`: `http_request_pre_credentials`, `http_response_pre_return`, or `websocket_message_pre_credentials`. An allowlist ignores additional valid bindings; an exact policy rejects them. Omit this field for dynamic discovery. [Gateway Configuration](/how-it-works/gateways/configuration#supervisor-middleware-services) explains upgrade requirements. + + Time limit for `Describe`, `ValidateConfig`, and individual evaluations or stream exchanges. Use an integer followed by `ms` or `s`, from `10ms` through `30s`. A binding may advertise a shorter evaluation timeout. Accepted HTTP response and WebSocket streams have no connection-wide deadline. diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 7f16929759..3517221da4 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -543,13 +543,30 @@ max_payload_bytes = 262144 timeout = "500ms" ``` -Each service implements the supervisor middleware gRPC contract and exposes bindings through `Describe`. Policies reference the operator-owned registration `name`, attaching the complete middleware and all of its bindings. Bindings are identified by operation and phase. A manifest may expose at most one binding for each operation and phase pair. V1 supports `HttpRequest/pre_credentials`, `HttpResponse/pre_return`, and `WebSocketMessage/pre_credentials`. Registration names must be unique, and operator-run registrations cannot claim the reserved `openshell/` namespace. The service-reported manifest name is diagnostic metadata and does not need to match the registration name. +Each service advertises operation and phase pairs through `Describe`. A sandbox policy attaches the service by its registration `name`; the gateway operator controls which bindings that attachment can use. Registration names must be unique and cannot start with the reserved `openshell/` namespace. The name in the service manifest is for diagnostics only. + +| `binding_policy` | What the gateway and supervisor require | +| --- | --- | +| `dynamic` (default) | Use all valid advertised bindings. Omit `bindings`. A service update can change which hooks run. | +| `allowlist` | Require every entry in `bindings`. Ignore additional valid bindings the service advertises. | +| `exact` | Require the service to advertise exactly the entries in `bindings`. | + +For example, add the following lines to the registration above to require a request hook even if the service also offers a response hook: + +```toml +binding_policy = "allowlist" +bindings = ["http_request_pre_credentials"] +``` + +The other supported bindings are `http_response_pre_return` and `websocket_message_pre_credentials`. Use each binding at most once. To require the check on matching traffic, attach the service in sandbox policy and keep the default `on_error: fail_closed` behavior. + +Upgrade the gateway and all supervisors before relying on `allowlist` or `exact`. Older supervisors ignore these fields. A running supervisor that rejects a configuration update keeps its last accepted configuration; confirm the new configuration is active before depending on the change. The gateway connects to every registered service and validates `Describe` before it starts. The service must therefore be running before the gateway. Policy creation and full policy updates call `ValidateConfig`; an unavailable service or invalid middleware configuration rejects the policy before persistence. Startup also requires compatible [extension protocol metadata](/extensibility/overview#protocol-negotiation). Upgrade legacy gateways and operator-run services together during a coordinated outage; neither mixed legacy/current pairing is supported. A service rejects a gateway that omits peer metadata, and a gateway rejects a service that omits metadata, uses another protocol major, or requires unsupported gateway capabilities. Protected `GetGatewayInfo` and `openshell gateway info` report the resulting non-secret snapshot. -`max_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and response units, replacement bodies, and complete WebSocket text messages and replacements. Whole-response inspection uses it as the stage's total body limit. Streaming response inspection applies it to each unit. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport. +`max_payload_bytes` is the shared operator limit for inspectable logical payloads across the bindings authorized for this service. It caps HTTP request and response units, replacement bodies, and complete WebSocket text messages and replacements. Whole-response inspection uses it as the stage's total body limit. Streaming response inspection applies it to each unit. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport. `timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to HTTP request evaluation, HTTP response preflight and unit exchanges, WebSocket preflight, and each WebSocket message. Accepted streaming protocols have no connection-wide RPC deadline. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index bf1652389a..80344bd4a1 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -506,4 +506,32 @@ message SupervisorMiddlewareService { // no bearer credential; supervisors must not request one. Intended only for // trusted-network development deployments. bool allow_insecure_transport = 7; + // How the gateway and supervisor authorize the bindings returned by Describe. + // Absence preserves dynamic discovery. A supervisor predating this field + // cannot enforce a binding policy; upgrade supervisors before requiring one. + SupervisorMiddlewareBindingPolicy binding_policy = 8; + // Operation/phase pairs required by an allowlist or exact policy. Every + // listed binding must be advertised, and only listed bindings may run. + repeated SupervisorMiddlewareBinding bindings = 9; +} + +// Operator authority over the bindings in a supervisor middleware manifest. +enum SupervisorMiddlewareBindingPolicy { + // Older gateways omit this field, which selects dynamic discovery. + SUPERVISOR_MIDDLEWARE_BINDING_POLICY_UNSPECIFIED = 0; + // Accept all supported bindings in the manifest. + SUPERVISOR_MIDDLEWARE_BINDING_POLICY_DYNAMIC = 1; + // Require and enable the listed bindings; ignore other supported bindings. + SUPERVISOR_MIDDLEWARE_BINDING_POLICY_ALLOWLIST = 2; + // Require the listed bindings and reject additional bindings. + SUPERVISOR_MIDDLEWARE_BINDING_POLICY_EXACT = 3; +} + +// An operation/phase pair that the platform can authorize. These identify +// platform capabilities, not service-owned binding names. +enum SupervisorMiddlewareBinding { + SUPERVISOR_MIDDLEWARE_BINDING_UNSPECIFIED = 0; + SUPERVISOR_MIDDLEWARE_BINDING_HTTP_REQUEST_PRE_CREDENTIALS = 1; + SUPERVISOR_MIDDLEWARE_BINDING_HTTP_RESPONSE_PRE_RETURN = 2; + SUPERVISOR_MIDDLEWARE_BINDING_WEBSOCKET_MESSAGE_PRE_CREDENTIALS = 3; } diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 03c490d193..ef4b683a22 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -298,6 +298,117 @@ func (PolicySource) EnumDescriptor() ([]byte, []int) { return file_sandbox_proto_rawDescGZIP(), []int{4} } +// Operator authority over the bindings in a supervisor middleware manifest. +type SupervisorMiddlewareBindingPolicy int32 + +const ( + // Older gateways omit this field, which selects dynamic discovery. + SupervisorMiddlewareBindingPolicy_SUPERVISOR_MIDDLEWARE_BINDING_POLICY_UNSPECIFIED SupervisorMiddlewareBindingPolicy = 0 + // Accept all supported bindings in the manifest. + SupervisorMiddlewareBindingPolicy_SUPERVISOR_MIDDLEWARE_BINDING_POLICY_DYNAMIC SupervisorMiddlewareBindingPolicy = 1 + // Require and enable the listed bindings; ignore other supported bindings. + SupervisorMiddlewareBindingPolicy_SUPERVISOR_MIDDLEWARE_BINDING_POLICY_ALLOWLIST SupervisorMiddlewareBindingPolicy = 2 + // Require the listed bindings and reject additional bindings. + SupervisorMiddlewareBindingPolicy_SUPERVISOR_MIDDLEWARE_BINDING_POLICY_EXACT SupervisorMiddlewareBindingPolicy = 3 +) + +// Enum value maps for SupervisorMiddlewareBindingPolicy. +var ( + SupervisorMiddlewareBindingPolicy_name = map[int32]string{ + 0: "SUPERVISOR_MIDDLEWARE_BINDING_POLICY_UNSPECIFIED", + 1: "SUPERVISOR_MIDDLEWARE_BINDING_POLICY_DYNAMIC", + 2: "SUPERVISOR_MIDDLEWARE_BINDING_POLICY_ALLOWLIST", + 3: "SUPERVISOR_MIDDLEWARE_BINDING_POLICY_EXACT", + } + SupervisorMiddlewareBindingPolicy_value = map[string]int32{ + "SUPERVISOR_MIDDLEWARE_BINDING_POLICY_UNSPECIFIED": 0, + "SUPERVISOR_MIDDLEWARE_BINDING_POLICY_DYNAMIC": 1, + "SUPERVISOR_MIDDLEWARE_BINDING_POLICY_ALLOWLIST": 2, + "SUPERVISOR_MIDDLEWARE_BINDING_POLICY_EXACT": 3, + } +) + +func (x SupervisorMiddlewareBindingPolicy) Enum() *SupervisorMiddlewareBindingPolicy { + p := new(SupervisorMiddlewareBindingPolicy) + *p = x + return p +} + +func (x SupervisorMiddlewareBindingPolicy) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SupervisorMiddlewareBindingPolicy) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[5].Descriptor() +} + +func (SupervisorMiddlewareBindingPolicy) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[5] +} + +func (x SupervisorMiddlewareBindingPolicy) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SupervisorMiddlewareBindingPolicy.Descriptor instead. +func (SupervisorMiddlewareBindingPolicy) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{5} +} + +// An operation/phase pair that the platform can authorize. These identify +// platform capabilities, not service-owned binding names. +type SupervisorMiddlewareBinding int32 + +const ( + SupervisorMiddlewareBinding_SUPERVISOR_MIDDLEWARE_BINDING_UNSPECIFIED SupervisorMiddlewareBinding = 0 + SupervisorMiddlewareBinding_SUPERVISOR_MIDDLEWARE_BINDING_HTTP_REQUEST_PRE_CREDENTIALS SupervisorMiddlewareBinding = 1 + SupervisorMiddlewareBinding_SUPERVISOR_MIDDLEWARE_BINDING_HTTP_RESPONSE_PRE_RETURN SupervisorMiddlewareBinding = 2 + SupervisorMiddlewareBinding_SUPERVISOR_MIDDLEWARE_BINDING_WEBSOCKET_MESSAGE_PRE_CREDENTIALS SupervisorMiddlewareBinding = 3 +) + +// Enum value maps for SupervisorMiddlewareBinding. +var ( + SupervisorMiddlewareBinding_name = map[int32]string{ + 0: "SUPERVISOR_MIDDLEWARE_BINDING_UNSPECIFIED", + 1: "SUPERVISOR_MIDDLEWARE_BINDING_HTTP_REQUEST_PRE_CREDENTIALS", + 2: "SUPERVISOR_MIDDLEWARE_BINDING_HTTP_RESPONSE_PRE_RETURN", + 3: "SUPERVISOR_MIDDLEWARE_BINDING_WEBSOCKET_MESSAGE_PRE_CREDENTIALS", + } + SupervisorMiddlewareBinding_value = map[string]int32{ + "SUPERVISOR_MIDDLEWARE_BINDING_UNSPECIFIED": 0, + "SUPERVISOR_MIDDLEWARE_BINDING_HTTP_REQUEST_PRE_CREDENTIALS": 1, + "SUPERVISOR_MIDDLEWARE_BINDING_HTTP_RESPONSE_PRE_RETURN": 2, + "SUPERVISOR_MIDDLEWARE_BINDING_WEBSOCKET_MESSAGE_PRE_CREDENTIALS": 3, + } +) + +func (x SupervisorMiddlewareBinding) Enum() *SupervisorMiddlewareBinding { + p := new(SupervisorMiddlewareBinding) + *p = x + return p +} + +func (x SupervisorMiddlewareBinding) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SupervisorMiddlewareBinding) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[6].Descriptor() +} + +func (SupervisorMiddlewareBinding) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[6] +} + +func (x SupervisorMiddlewareBinding) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SupervisorMiddlewareBinding.Descriptor instead. +func (SupervisorMiddlewareBinding) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{6} +} + // Sandbox security policy configuration. type SandboxPolicy struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -841,7 +952,6 @@ type NetworkEndpoint struct { // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: // - If host is also set: domain must resolve to an IP in this list. // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. - // // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked // regardless of this field. @@ -2386,8 +2496,15 @@ type SupervisorMiddlewareService struct { // no bearer credential; supervisors must not request one. Intended only for // trusted-network development deployments. AllowInsecureTransport bool `protobuf:"varint,7,opt,name=allow_insecure_transport,json=allowInsecureTransport,proto3" json:"allow_insecure_transport,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // How the gateway and supervisor authorize the bindings returned by Describe. + // Absence preserves dynamic discovery. A supervisor predating this field + // cannot enforce a binding policy; upgrade supervisors before requiring one. + BindingPolicy SupervisorMiddlewareBindingPolicy `protobuf:"varint,8,opt,name=binding_policy,json=bindingPolicy,proto3,enum=openshell.sandbox.v1.SupervisorMiddlewareBindingPolicy" json:"binding_policy,omitempty"` + // Operation/phase pairs required by an allowlist or exact policy. Every + // listed binding must be advertised, and only listed bindings may run. + Bindings []SupervisorMiddlewareBinding `protobuf:"varint,9,rep,packed,name=bindings,proto3,enum=openshell.sandbox.v1.SupervisorMiddlewareBinding" json:"bindings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SupervisorMiddlewareService) Reset() { @@ -2469,6 +2586,20 @@ func (x *SupervisorMiddlewareService) GetAllowInsecureTransport() bool { return false } +func (x *SupervisorMiddlewareService) GetBindingPolicy() SupervisorMiddlewareBindingPolicy { + if x != nil { + return x.BindingPolicy + } + return SupervisorMiddlewareBindingPolicy_SUPERVISOR_MIDDLEWARE_BINDING_POLICY_UNSPECIFIED +} + +func (x *SupervisorMiddlewareService) GetBindings() []SupervisorMiddlewareBinding { + if x != nil { + return x.Bindings + } + return nil +} + var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + @@ -2669,7 +2800,7 @@ const file_sandbox_proto_rawDesc = "" + "\x11settings_revision\x18\x11 \x01(\x04R\x10settingsRevision\x1ac\n" + "\rSettingsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + - "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\xd2\x02\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\x81\x04\n" + "\x1bSupervisorMiddlewareService\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + "\rgrpc_endpoint\x18\x02 \x01(\tR\fgrpcEndpoint\x12*\n" + @@ -2677,7 +2808,9 @@ const file_sandbox_proto_rawDesc = "" + "\x0frequest_timeout\x18h \x01(\v2\x19.google.protobuf.DurationR\x0erequestTimeout\x12%\n" + "\x0ftls_ca_cert_pem\x18\x05 \x01(\fR\ftlsCaCertPem\x12\x1a\n" + "\baudience\x18\x06 \x01(\tR\baudience\x128\n" + - "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransportJ\x04\b\x04\x10\x05R\atimeout*\x97\x01\n" + + "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransport\x12^\n" + + "\x0ebinding_policy\x18\b \x01(\x0e27.openshell.sandbox.v1.SupervisorMiddlewareBindingPolicyR\rbindingPolicy\x12M\n" + + "\bbindings\x18\t \x03(\x0e21.openshell.sandbox.v1.SupervisorMiddlewareBindingR\bbindingsJ\x04\b\x04\x10\x05R\atimeout*\x97\x01\n" + "\x0eNetworkTlsMode\x12 \n" + "\x1cNETWORK_TLS_MODE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15NETWORK_TLS_MODE_SKIP\x10\x01\x12\"\n" + @@ -2699,7 +2832,17 @@ const file_sandbox_proto_rawDesc = "" + "\fPolicySource\x12\x1d\n" + "\x19POLICY_SOURCE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15POLICY_SOURCE_SANDBOX\x10\x01\x12\x18\n" + - "\x14POLICY_SOURCE_GLOBAL\x10\x02b\x06proto3" + "\x14POLICY_SOURCE_GLOBAL\x10\x02*\xef\x01\n" + + "!SupervisorMiddlewareBindingPolicy\x124\n" + + "0SUPERVISOR_MIDDLEWARE_BINDING_POLICY_UNSPECIFIED\x10\x00\x120\n" + + ",SUPERVISOR_MIDDLEWARE_BINDING_POLICY_DYNAMIC\x10\x01\x122\n" + + ".SUPERVISOR_MIDDLEWARE_BINDING_POLICY_ALLOWLIST\x10\x02\x12.\n" + + "*SUPERVISOR_MIDDLEWARE_BINDING_POLICY_EXACT\x10\x03*\x8d\x02\n" + + "\x1bSupervisorMiddlewareBinding\x12-\n" + + ")SUPERVISOR_MIDDLEWARE_BINDING_UNSPECIFIED\x10\x00\x12>\n" + + ":SUPERVISOR_MIDDLEWARE_BINDING_HTTP_REQUEST_PRE_CREDENTIALS\x10\x01\x12:\n" + + "6SUPERVISOR_MIDDLEWARE_BINDING_HTTP_RESPONSE_PRE_RETURN\x10\x02\x12C\n" + + "?SUPERVISOR_MIDDLEWARE_BINDING_WEBSOCKET_MESSAGE_PRE_CREDENTIALS\x10\x03b\x06proto3" var ( file_sandbox_proto_rawDescOnce sync.Once @@ -2713,103 +2856,107 @@ func file_sandbox_proto_rawDescGZIP() []byte { return file_sandbox_proto_rawDescData } -var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 7) var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 34) var file_sandbox_proto_goTypes = []any{ - (NetworkTlsMode)(0), // 0: openshell.sandbox.v1.NetworkTlsMode - (NetworkEnforcementMode)(0), // 1: openshell.sandbox.v1.NetworkEnforcementMode - (NetworkAccessPreset)(0), // 2: openshell.sandbox.v1.NetworkAccessPreset - (SettingScope)(0), // 3: openshell.sandbox.v1.SettingScope - (PolicySource)(0), // 4: openshell.sandbox.v1.PolicySource - (*SandboxPolicy)(nil), // 5: openshell.sandbox.v1.SandboxPolicy - (*FilesystemPolicy)(nil), // 6: openshell.sandbox.v1.FilesystemPolicy - (*LandlockPolicy)(nil), // 7: openshell.sandbox.v1.LandlockPolicy - (*ProcessPolicy)(nil), // 8: openshell.sandbox.v1.ProcessPolicy - (*NetworkPolicyRule)(nil), // 9: openshell.sandbox.v1.NetworkPolicyRule - (*NetworkMiddlewareConfig)(nil), // 10: openshell.sandbox.v1.NetworkMiddlewareConfig - (*MiddlewareEndpointSelector)(nil), // 11: openshell.sandbox.v1.MiddlewareEndpointSelector - (*NetworkCredentialBinding)(nil), // 12: openshell.sandbox.v1.NetworkCredentialBinding - (*NetworkEndpoint)(nil), // 13: openshell.sandbox.v1.NetworkEndpoint - (*McpOptions)(nil), // 14: openshell.sandbox.v1.McpOptions - (*GraphqlOperation)(nil), // 15: openshell.sandbox.v1.GraphqlOperation - (*L7DenyRule)(nil), // 16: openshell.sandbox.v1.L7DenyRule - (*L7Rule)(nil), // 17: openshell.sandbox.v1.L7Rule - (*L7Allow)(nil), // 18: openshell.sandbox.v1.L7Allow - (*L7QueryMatcher)(nil), // 19: openshell.sandbox.v1.L7QueryMatcher - (*NetworkBinary)(nil), // 20: openshell.sandbox.v1.NetworkBinary - (*GetSandboxConfigRequest)(nil), // 21: openshell.sandbox.v1.GetSandboxConfigRequest - (*GetGatewayConfigRequest)(nil), // 22: openshell.sandbox.v1.GetGatewayConfigRequest - (*GetGatewayConfigResponse)(nil), // 23: openshell.sandbox.v1.GetGatewayConfigResponse - (*SettingValue)(nil), // 24: openshell.sandbox.v1.SettingValue - (*EffectiveSetting)(nil), // 25: openshell.sandbox.v1.EffectiveSetting - (*SandboxConfigSnapshot)(nil), // 26: openshell.sandbox.v1.SandboxConfigSnapshot - (*GetSandboxConfigResponse)(nil), // 27: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 28: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 30: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 31: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 32: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 33: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 34: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 35: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 36: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 37: openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry - nil, // 38: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 39: google.protobuf.Struct - (*datamodelv1.WorkspaceSelector)(nil), // 40: openshell.datamodel.v1.WorkspaceSelector - (*durationpb.Duration)(nil), // 41: google.protobuf.Duration + (NetworkTlsMode)(0), // 0: openshell.sandbox.v1.NetworkTlsMode + (NetworkEnforcementMode)(0), // 1: openshell.sandbox.v1.NetworkEnforcementMode + (NetworkAccessPreset)(0), // 2: openshell.sandbox.v1.NetworkAccessPreset + (SettingScope)(0), // 3: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 4: openshell.sandbox.v1.PolicySource + (SupervisorMiddlewareBindingPolicy)(0), // 5: openshell.sandbox.v1.SupervisorMiddlewareBindingPolicy + (SupervisorMiddlewareBinding)(0), // 6: openshell.sandbox.v1.SupervisorMiddlewareBinding + (*SandboxPolicy)(nil), // 7: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 8: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 9: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 10: openshell.sandbox.v1.ProcessPolicy + (*NetworkPolicyRule)(nil), // 11: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkMiddlewareConfig)(nil), // 12: openshell.sandbox.v1.NetworkMiddlewareConfig + (*MiddlewareEndpointSelector)(nil), // 13: openshell.sandbox.v1.MiddlewareEndpointSelector + (*NetworkCredentialBinding)(nil), // 14: openshell.sandbox.v1.NetworkCredentialBinding + (*NetworkEndpoint)(nil), // 15: openshell.sandbox.v1.NetworkEndpoint + (*McpOptions)(nil), // 16: openshell.sandbox.v1.McpOptions + (*GraphqlOperation)(nil), // 17: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 18: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 19: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 20: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 21: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 22: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 24: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 25: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 26: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 27: openshell.sandbox.v1.EffectiveSetting + (*SandboxConfigSnapshot)(nil), // 28: openshell.sandbox.v1.SandboxConfigSnapshot + (*GetSandboxConfigResponse)(nil), // 29: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 30: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 31: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 32: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 33: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 34: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 35: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 36: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 37: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 38: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 39: openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry + nil, // 40: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 41: google.protobuf.Struct + (*datamodelv1.WorkspaceSelector)(nil), // 42: openshell.datamodel.v1.WorkspaceSelector + (*durationpb.Duration)(nil), // 43: google.protobuf.Duration } var file_sandbox_proto_depIdxs = []int32{ - 6, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy - 7, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy - 8, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy - 29, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - 30, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - 13, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 20, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 39, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct - 11, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector + 8, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 9, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 10, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 31, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 32, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + 15, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 22, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 41, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 13, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector 0, // 9: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode 1, // 10: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode 2, // 11: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset - 17, // 12: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 16, // 13: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 31, // 14: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 14, // 15: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 12, // 16: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 32, // 17: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 33, // 18: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry - 18, // 19: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 34, // 20: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 35, // 21: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 40, // 22: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 36, // 23: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 24, // 24: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 19, // 12: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 18, // 13: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 33, // 14: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 16, // 15: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 14, // 16: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 34, // 17: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 35, // 18: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 20, // 19: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 36, // 20: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 37, // 21: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 42, // 22: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 38, // 23: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 26, // 24: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue 3, // 25: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 5, // 26: openshell.sandbox.v1.SandboxConfigSnapshot.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 37, // 27: openshell.sandbox.v1.SandboxConfigSnapshot.settings:type_name -> openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry + 7, // 26: openshell.sandbox.v1.SandboxConfigSnapshot.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 39, // 27: openshell.sandbox.v1.SandboxConfigSnapshot.settings:type_name -> openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry 4, // 28: openshell.sandbox.v1.SandboxConfigSnapshot.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 28, // 29: openshell.sandbox.v1.SandboxConfigSnapshot.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 5, // 30: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 38, // 31: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 30, // 29: openshell.sandbox.v1.SandboxConfigSnapshot.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 7, // 30: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 40, // 31: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry 4, // 32: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 28, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 41, // 34: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration - 9, // 35: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 10, // 36: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 15, // 37: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 19, // 38: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 39: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 40: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 41: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 24, // 42: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 25, // 43: openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 25, // 44: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 45, // [45:45] is the sub-list for method output_type - 45, // [45:45] is the sub-list for method input_type - 45, // [45:45] is the sub-list for extension type_name - 45, // [45:45] is the sub-list for extension extendee - 0, // [0:45] is the sub-list for field type_name + 30, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 43, // 34: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration + 5, // 35: openshell.sandbox.v1.SupervisorMiddlewareService.binding_policy:type_name -> openshell.sandbox.v1.SupervisorMiddlewareBindingPolicy + 6, // 36: openshell.sandbox.v1.SupervisorMiddlewareService.bindings:type_name -> openshell.sandbox.v1.SupervisorMiddlewareBinding + 11, // 37: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 12, // 38: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 17, // 39: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 21, // 40: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 41: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 42: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 43: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 26, // 44: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 27, // 45: openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 27, // 46: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 47, // [47:47] is the sub-list for method output_type + 47, // [47:47] is the sub-list for method input_type + 47, // [47:47] is the sub-list for extension type_name + 47, // [47:47] is the sub-list for extension extendee + 0, // [0:47] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2829,7 +2976,7 @@ func file_sandbox_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), - NumEnums: 5, + NumEnums: 7, NumMessages: 34, NumExtensions: 0, NumServices: 0, From 0411584c85bfc35369559d1d09a943d80f80604a Mon Sep 17 00:00:00 2001 From: Free Wortley Date: Thu, 22 Oct 2026 00:22:15 +0000 Subject: [PATCH 2/3] docs(skills): check which supervisor middleware hooks the operator authorizes Signed-off-by: Free Wortley --- .../references/supervisor-middleware.md | 10 ++++++++-- skills/generate-sandbox-policy/SKILL.md | 9 +++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/skills/debug-openshell-cluster/references/supervisor-middleware.md b/skills/debug-openshell-cluster/references/supervisor-middleware.md index 7f999fcf4e..cdd9849e20 100644 --- a/skills/debug-openshell-cluster/references/supervisor-middleware.md +++ b/skills/debug-openshell-cluster/references/supervisor-middleware.md @@ -12,7 +12,7 @@ for registration settings. For operator-run supervisor middleware, inspect `[[openshell.supervisor.middleware]]`, service reachability, and both gateway and supervisor logs: ```shell -rg -n 'supervisor|middleware|grpc_endpoint|tls_ca_cert_path|audience|allow_insecure_transport|max_payload_bytes|timeout|gateway_jwt' /etc/openshell/gateway.toml +rg -n 'supervisor|middleware|grpc_endpoint|tls_ca_cert_path|audience|allow_insecure_transport|binding_policy|bindings|max_payload_bytes|timeout|gateway_jwt' /etc/openshell/gateway.toml journalctl -u --no-pager --lines=200 journalctl -u openshell-gateway --no-pager --lines=200 openshell logs --tail --source sandbox @@ -24,6 +24,12 @@ The middleware service must start before the gateway and be reachable from both When gateway JWT signing is disabled, supervisors preserve the legacy unauthenticated connector and do not request extension credentials. When signing is enabled, credential acquisition and verification failures are fail closed: check HTTPS trust and hostname validation, audience and issuer agreement, the token `kid`, gateway `RefreshSandboxToken` errors, and middleware logs. Changing a registration requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. +## A matching request skips the middleware + +Compare the service's `Describe` bindings with `binding_policy` and `bindings` in its gateway registration. The gateway checks its own `Describe` response at startup; the supervisor checks again when it installs the registration. The default `dynamic` policy accepts all valid advertised bindings. An `allowlist` requires every listed binding and ignores extras; `exact` also rejects extras. + +For example, if the service advertises both HTTP hooks but the allowlist is `bindings = ["http_response_pre_return"]`, only the response hook is selected. Requests skip this middleware even with `on_error: fail_closed`, because that setting applies only after a stage is selected. If request inspection is required, include `http_request_pre_credentials` in the registration, restart the gateway, and confirm the supervisor accepted the change. Older supervisors ignore these fields; a running supervisor that rejects an update keeps its last accepted configuration. + ## HTTP response failures For response failures, distinguish a deliberate `middleware_denied` decision from `response_delivery_failed`. Before response commitment, they produce canonical 403 and 502 responses respectively. After commitment, OpenShell aborts without adding an error body, final chunk, or trailer. A whole-body accumulation timeout is one fixed, non-resetting 120-second wall-clock deadline shared across response reads and whole-body barriers; inspect the active stage's `on_error` and `whole_body_accumulation_timeout` diagnostics. Header-only stages preserve upstream body framing. Body-processing stages normalize framing and send a trailer exchange, including an empty trailer set, after the final body result. @@ -48,7 +54,7 @@ WebSocket message sequences are allocated session-wide; each stage receives a st | Policy update rejects `network_middlewares` | Unknown middleware name, implementation-owned config invalid, duplicate order, broad/invalid host selector, or fail-closed coverage of `tls: skip` | Policy error, gateway logs, middleware `ValidateConfig`, selector and order fields | | HTTP request returns `middleware_failed` or `middleware_denied`, or WebSocket closes with `1008` | Selected stage failed or explicitly denied admitted traffic | Sandbox OCSF logs; policy-local middleware config; service availability; binding operation; `on_error` | | HTTP response becomes canonical `403 middleware_denied`, `502 response_delivery_failed`, or closes mid-body | Response middleware blocked, failed before commitment, or stopped delivery after commitment | Sandbox OCSF response middleware events; `HTTP_RESPONSE/PRE_RETURN` binding; `on_error`; `whole_body_accumulation_timeout`; service stream lifecycle | -| WebSocket upgrades but a host-matched middleware receives no preflight or message RPC | The implementation did not advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` | `WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected`; service `Describe`; the upgrade GET may still have used its HTTP binding | +| WebSocket upgrades but a host-matched middleware receives no preflight or message RPC | The service did not advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, or its registration did not authorize that binding | `WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected`; compare `Describe` with `binding_policy` and `bindings`; the upgrade GET may still have used its HTTP binding | | Binary WebSocket message passes without a middleware RPC | Binary is unsupported by the V1 text-message binding under both `on_error` modes | `WEBSOCKET_MIDDLEWARE_COVERAGE state=unsupported_message_type`; the next text RPC may have a valid sequence gap | | WebSocket messages stop reaching middleware after one failure | A fail-open stage stream was disabled for the rest of the connection | `openshell.middleware.websocket_stage_disabled`; middleware timeout/stream/protocol logs. A per-message capacity bypass alone leaves the stage active. Reconnect to create a fresh stream after a genuine stream failure | | Supervisor repeatedly fails to install middleware after enabling gateway JWT signing | Extension credential minting, distribution, or authenticated service connection failed; last-known-good registry remains active | Gateway `RefreshSandboxToken` logs, sandbox configuration events, service token-verification logs, registration TLS/audience settings | diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 67245d1ba5..80690c45d1 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -224,7 +224,8 @@ Add `network_middlewares` only when the user asks to inspect, transform, redact, - Use `openshell/regex` without gateway registration for fixed-pattern redaction of UTF-8 HTTP request bodies or complete client-to-upstream WebSocket text messages. - Use an operator-owned middleware name only when it is already registered under `[[openshell.supervisor.middleware]]` and reachable from both the gateway and sandbox supervisors. -- Confirm that the implementation advertises the requested binding: `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, or `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. A host match alone does not enable inspection. +- Confirm that the service advertises the needed binding in `Describe` and that the operator's `binding_policy` authorizes it. For example, if a service advertises both HTTP hooks but the registration allows only `http_response_pre_return`, matching requests skip this middleware even with `on_error: fail_closed`. +- To require request inspection, ask the operator to include `http_request_pre_credentials` in `bindings` with `binding_policy = "allowlist"` or `"exact"`. The default `dynamic` policy follows what the service advertises; sandbox policy alone cannot require a missing binding. Confirm the gateway and all supervisors support binding policies before relying on them. - WebSocket middleware inspects client text messages only, over both `ws://` and `wss://`. Binary and upstream-to-client messages pass without inspection, even with `fail_closed`. - `on_error` controls selected-stage failures. Explicit denials always block traffic. A failed WebSocket stage with `fail_open` can remain bypassed for the rest of the connection. - Default `on_error` to `fail_closed`. Use `fail_open` only when bypassing the stage preserves the user's stated security requirement. @@ -403,8 +404,8 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] Every middleware config has a non-empty `middleware` name and non-empty `endpoints.include` - [ ] Middleware `order` values are unique and no selected chain exceeds 10 stages - [ ] No fail-closed middleware selector can cover a `tls: skip` endpoint -- [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages -- [ ] Any required response control advertises `HTTP_RESPONSE/PRE_RETURN` +- [ ] If inspection by an operator-run service is mandatory, the registration requires the binding (`http_request_pre_credentials`, `http_response_pre_return`, or `websocket_message_pre_credentials`), the service advertises it, and the attachment uses `on_error: fail_closed` +- [ ] The user understands that V1 WebSocket middleware inspects client text messages only - [ ] Endpoints contributed by a credentialed provider are not L4-only or `tls: skip` unless `allow_uninspected_credentials: true` explicitly records the exception ### Schema Warnings (log-only, but should be fixed) @@ -439,7 +440,7 @@ Evaluate the generated policy for overly broad access and **include warnings in | **Hostless `allowed_ips`** (no `host` field and no `protocol: tcp`) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted through the legacy proxy. Consider adding a `host` field to restrict which domains can use this allowlist." | | **Broad CIDR** in `allowed_ips` (e.g., `10.0.0.0/8`) | "This `allowed_ips` entry covers a very broad range. Consider narrowing to a specific subnet (e.g., `10.0.5.0/24`) to minimize exposure." | | **`on_error: fail_open`** | "This middleware can be bypassed when it is unavailable, rejects configuration, returns an invalid result, or exceeds its body limit. Use `fail_closed` unless availability is more important than this control." | -| **Broad middleware host selector** | "This middleware attaches independently of the admitting network rule to every matching destination, then runs only for operation bindings its implementation advertises. Narrow `endpoints.include` or add exclusions if the attachment is not required for every matching host." | +| **Broad middleware host selector** | "This middleware attaches independently of the admitting network rule to every matching destination. For operator-run middleware, only bindings the service advertises and the registration allows can run. Narrow `endpoints.include` or add exclusions if the attachment is not required for every matching host." | | **`allow_uninspected_credentials: true`** | "This endpoint may carry provider credentials on traffic OpenShell cannot inspect or rewrite. Prefer an inspected protocol and credential rewrite; keep this exception only when raw traffic is required." | Format breadth warnings clearly in the output, e.g.: From 445072cc39f01db40382556e092ec23376c2c7de Mon Sep 17 00:00:00 2001 From: Free Wortley Date: Thu, 22 Oct 2026 02:10:44 +0000 Subject: [PATCH 3/3] fix(go): regenerate the sandbox protobuf binding Restore the comment separator emitted by the pinned generator. This keeps the checked-in binding byte-for-byte reproducible without changing the protobuf schema or Go behavior. Signed-off-by: Free Wortley --- sdk/go/proto/sandboxv1/sandbox.pb.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index ef4b683a22..4b09b2016c 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -952,6 +952,7 @@ type NetworkEndpoint struct { // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: // - If host is also set: domain must resolve to an IP in this list. // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked // regardless of this field.