From 4030306e3ec0b14fcd9e7bd76d7ccd25ca658f57 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 31 Aug 2026 18:56:49 -0700 Subject: [PATCH 1/6] feat(middleware): broaden HTTP header mutation authority Remove the request write-prefix restriction and route request and response mutations through one direction-aware atomic applicator.\n\nRefs #2691 Signed-off-by: Piotr Mlocek --- .../src/headers.rs | 168 +++++++++++++++--- .../src/lib.rs | 102 ++++++++++- docs/extensibility/supervisor-middleware.mdx | 2 +- proto/supervisor_middleware.proto | 5 +- rfc/0009-supervisor-middleware/README.md | 4 +- .../appendices/protocol-extensions.md | 2 +- 6 files changed, 252 insertions(+), 31 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs index d4b1168f60..283c47cc53 100644 --- a/crates/openshell-supervisor-middleware/src/headers.rs +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -1,20 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Validation and logical application of middleware request-header mutations. +//! Validation and logical application of HTTP middleware header mutations. use openshell_core::proto::{ExistingHeaderAction, HeaderMutation, HttpHeader, header_mutation}; pub const MAX_HEADER_MUTATIONS: usize = 64; pub const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024; +/// Selects the protected-header rules for the HTTP message being mutated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeaderAuthority { + Request, + Response, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum HeaderMutationError { TooMany { count: usize }, InvalidName { name: String }, Protected { name: String }, HopByHop { name: String }, - WriteNamespace { name: String }, UnsafeValue { name: String }, TooLarge, InvalidExistingAction, @@ -31,7 +37,6 @@ impl HeaderMutationError { Self::InvalidName { .. } => "header_mutation_invalid_name", Self::Protected { .. } => "header_mutation_protected_header", Self::HopByHop { .. } => "header_mutation_hop_by_hop_header", - Self::WriteNamespace { .. } => "header_mutation_write_namespace", Self::UnsafeValue { .. } => "header_mutation_unsafe_value", Self::TooLarge => "header_mutation_bytes_over_capacity", Self::InvalidExistingAction => "header_mutation_invalid_existing_action", @@ -67,10 +72,6 @@ impl std::fmt::Display for HeaderMutationError { "middleware cannot mutate hop-by-hop header '{name}'" ) } - Self::WriteNamespace { name } => write!( - formatter, - "middleware can only write request headers prefixed with x-openshell-middleware- and cannot write '{name}'" - ), Self::UnsafeValue { name } => { write!( formatter, @@ -105,6 +106,7 @@ impl std::error::Error for HeaderMutationError {} /// state observed by the next middleware. Repeated values and wire order are /// preserved; comparisons are case-insensitive. pub fn apply( + authority: HeaderAuthority, existing_headers: &[HttpHeader], connection_nominated_headers: &[String], mutations: &[HeaderMutation], @@ -121,16 +123,12 @@ pub fn apply( match mutation.operation.as_ref() { Some(header_mutation::Operation::Write(write)) => { let name = validate_name(&write.name)?; + validate_authority(authority, MutationKind::Write, &write.name, &name)?; if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { name: write.name.clone(), }); } - if !name.starts_with("x-openshell-middleware-") { - return Err(HeaderMutationError::WriteNamespace { - name: write.name.clone(), - }); - } if !is_safe_value(&write.value) { return Err(HeaderMutationError::UnsafeValue { name: write.name.clone(), @@ -166,6 +164,7 @@ pub fn apply( } Some(header_mutation::Operation::Remove(remove)) => { let name = validate_name(&remove.name)?; + validate_authority(authority, MutationKind::Remove, &remove.name, &name)?; if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { name: remove.name.clone(), @@ -195,12 +194,34 @@ fn validate_name(name: &str) -> Result { name: name.to_string(), }); } - if is_protected(&lower) { + Ok(lower) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MutationKind { + Write, + Remove, +} + +fn validate_authority( + authority: HeaderAuthority, + kind: MutationKind, + original_name: &str, + normalized_name: &str, +) -> Result<(), HeaderMutationError> { + let protected = match authority { + HeaderAuthority::Request => is_request_protected(normalized_name), + HeaderAuthority::Response => { + is_response_protected(normalized_name) + || (kind == MutationKind::Write && is_response_remove_only(normalized_name)) + } + }; + if protected { return Err(HeaderMutationError::Protected { - name: name.to_string(), + name: original_name.to_string(), }); } - Ok(lower) + Ok(()) } fn is_name_token_byte(byte: u8) -> bool { @@ -233,7 +254,7 @@ fn is_safe_value(value: &str) -> bool { .all(|byte| byte == b'\t' || (0x20..=0x7e).contains(&byte) || byte >= 0x80) } -fn is_protected(name: &str) -> bool { +fn is_request_protected(name: &str) -> bool { matches!( name, "authorization" @@ -253,6 +274,38 @@ fn is_protected(name: &str) -> bool { || name.starts_with("x-openshell-credential") } +fn is_response_protected(name: &str) -> bool { + matches!( + name, + "connection" + | "content-encoding" + | "content-length" + | "content-range" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +fn is_response_remove_only(name: &str) -> bool { + matches!( + name, + "accept-ranges" + | "etag" + | "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "signature" + | "signature-input" + ) +} + fn is_connection_nominated(connection_nominated_headers: &[String], name: &str) -> bool { connection_nominated_headers .iter() @@ -292,6 +345,7 @@ mod tests { #[test] fn protected_header_write_is_rejected() { let error = apply( + HeaderAuthority::Request, &[], &[], &[write( @@ -311,6 +365,7 @@ mod tests { #[test] fn unsafe_header_value_is_rejected() { let error = apply( + HeaderAuthority::Request, &[], &[], &[write( @@ -330,6 +385,7 @@ mod tests { header("accept", "application/json"), ]; let appended = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -349,6 +405,7 @@ mod tests { ); let overwritten = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -367,6 +424,7 @@ mod tests { ); let skipped = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -386,13 +444,25 @@ mod tests { header("accept", "application/json"), header("x-trace", "two"), ]; - let updated = apply(&existing, &[], &[remove("X-Trace")]).expect("remove visible header"); + let updated = apply( + HeaderAuthority::Request, + &existing, + &[], + &[remove("X-Trace")], + ) + .expect("remove visible header"); assert_eq!(updated, vec![header("accept", "application/json")]); } #[test] fn protected_header_remove_is_rejected_even_when_not_visible() { - let error = apply(&[], &[], &[remove("Authorization")]).expect_err("protected removal"); + let error = apply( + HeaderAuthority::Request, + &[], + &[], + &[remove("Authorization")], + ) + .expect_err("protected removal"); assert!( error .to_string() @@ -404,6 +474,7 @@ mod tests { fn connection_nominated_header_is_protected() { let nominated = vec!["x-openshell-middleware-tag".to_string()]; let write_error = apply( + HeaderAuthority::Request, &[], &nominated, &[write( @@ -419,12 +490,69 @@ mod tests { .contains("hop-by-hop header 'X-OpenShell-Middleware-Tag'") ); - let remove_error = apply(&[], &nominated, &[remove("X-OpenShell-Middleware-Tag")]) - .expect_err("hop-by-hop removal"); + let remove_error = apply( + HeaderAuthority::Request, + &[], + &nominated, + &[remove("X-OpenShell-Middleware-Tag")], + ) + .expect_err("hop-by-hop removal"); assert!( remove_error .to_string() .contains("hop-by-hop header 'X-OpenShell-Middleware-Tag'") ); } + + #[test] + fn request_write_accepts_end_to_end_header_without_namespace() { + let updated = apply( + HeaderAuthority::Request, + &[], + &[], + &[write( + "Cache-Control", + "no-store", + ExistingHeaderAction::Overwrite, + )], + ) + .expect("ordinary end-to-end request header"); + + assert_eq!(updated, vec![header("cache-control", "no-store")]); + } + + #[test] + fn response_authority_allows_end_to_end_writes_and_integrity_removal() { + let existing = [header("etag", "old"), header("content-type", "text/plain")]; + let updated = apply( + HeaderAuthority::Response, + &existing, + &[], + &[ + write("Cache-Control", "private", ExistingHeaderAction::Overwrite), + remove("ETag"), + ], + ) + .expect("permitted response mutations"); + + assert_eq!( + updated, + vec![ + header("content-type", "text/plain"), + header("cache-control", "private"), + ] + ); + } + + #[test] + fn response_authority_rejects_framing_and_integrity_writes() { + for mutation in [ + remove("Content-Length"), + write("ETag", "new", ExistingHeaderAction::Overwrite), + ] { + let error = apply(HeaderAuthority::Response, &[], &[], &[mutation]) + .expect_err("protected response mutation"); + assert!(matches!(error, HeaderMutationError::Protected { .. })); + } + } } diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 902b5165c2..bb27d26270 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -3,7 +3,7 @@ //! Supervisor middleware registration and chain execution. -mod headers; +pub mod headers; mod remote; mod websocket; @@ -1848,6 +1848,7 @@ impl ChainRunner { None } else { match headers::apply( + headers::HeaderAuthority::Request, &headers, &connection_nominated_headers, &result.header_mutations, @@ -3025,6 +3026,56 @@ mod tests { received: std::sync::Mutex>, } + struct InProcessHeaderChainService { + received: std::sync::Mutex>>, + } + + #[tonic::async_trait] + impl InProcessMiddleware for InProcessHeaderChainService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/in-process-header-chain".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + request: HttpRequestView<'_>, + ) -> Result { + let invocation = { + let mut received = self.received.lock().expect("in-process header chain lock"); + let invocation = received.len(); + received.push(request.headers().to_vec()); + invocation + }; + let mut result = allow_result(); + if invocation == 0 { + result.header_mutations.push(write_header( + "cache-control", + "no-store", + ExistingHeaderAction::Overwrite, + )); + } + Ok(result) + } + } + #[tonic::async_trait] impl SupervisorMiddleware for HeaderChainService { type EvaluateWebSocketSessionStream = WebSocketResponseStream; @@ -3081,13 +3132,13 @@ mod tests { let mut result = allow_result(); if invocation == 0 { result.header_mutations.push(write_header( - "x-openshell-middleware-chain", + "cache-control", "first", ExistingHeaderAction::Overwrite, )); } else if invocation == 1 { result.header_mutations.push(write_header( - "x-openshell-middleware-chain", + "cache-control", "second", self.second_action, )); @@ -3141,13 +3192,56 @@ mod tests { let observed: Vec<&str> = received[2] .headers .iter() - .filter(|header| header.name == "x-openshell-middleware-chain") + .filter(|header| header.name == "cache-control") .map(|header| header.value.as_str()) .collect(); assert_eq!(observed, expected, "action {action:?}"); } } + #[tokio::test] + async fn in_process_request_middleware_writes_end_to_end_header_without_namespace() { + let service = Arc::new(InProcessHeaderChainService { + received: std::sync::Mutex::new(Vec::new()), + }); + let runner = ChainRunner::new(service.clone()); + let entries = [ + ChainEntry { + name: "writer".into(), + implementation: "test/in-process-header-chain".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "observer".into(), + implementation: "test/in-process-header-chain".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ]; + + let outcome = runner + .evaluate(&entries, input("payload")) + .await + .expect("evaluate in-process header chain"); + let received = service + .received + .lock() + .expect("recorded in-process headers"); + + assert!(outcome.allowed); + assert_eq!( + received[1] + .iter() + .filter(|header| header.name == "cache-control") + .map(|header| header.value.as_str()) + .collect::>(), + vec!["no-store"] + ); + } + #[tokio::test] async fn repeated_request_headers_reach_middleware_in_wire_order() { // A map contract would collapse repeated header names to one value diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index 68cdbd29a5..3c0483dcd1 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -189,7 +189,7 @@ A middleware result can return ordered header mutations before OpenShell injects A `remove` mutation removes every value for a case-insensitive header name. OpenShell applies each successful stage's mutations before invoking the next middleware, so later stages observe the accumulated header state. -Header writes must use the `x-openshell-middleware-` prefix. Removes may target other middleware-visible request headers. Protected credential, routing, framing, and hop-by-hop headers are always rejected. Header values must not contain control characters. +Writes and removals may target middleware-visible end-to-end request headers without a required prefix. Protected credential, routing, framing, and hop-by-hop headers are always rejected. Header values must not contain control characters. OpenShell validates and applies each stage's mutations atomically. An invalid operation discards every mutation from that stage and follows its `on_error` behavior. Built-in failures can name the offending header. Operator-run failures use a platform-owned error code so request-derived header text cannot reach logs or denied responses. diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 27fd804bdf..bcd9c8ddb3 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -368,7 +368,7 @@ message RemoveHeader { string name = 1; } -// HeaderMutation is one ordered request-header operation. +// HeaderMutation is one ordered HTTP header operation. message HeaderMutation { oneof operation { WriteHeader write = 1; @@ -388,8 +388,7 @@ message HttpRequestResult { // True when body should replace the request body, including with an empty body. bool has_body = 4; // Ordered request-header mutations applied before the next middleware and - // before forwarding. Header writes are restricted to the - // "x-openshell-middleware-" namespace. Removes may target other visible + // before forwarding. Writes and removals may target visible end-to-end // request headers, but credential, routing, framing, and hop-by-hop headers // are always protected. A violating result is a middleware failure handled // according to the policy failure mode. At most 64 operations, 32 KiB of diff --git a/rfc/0009-supervisor-middleware/README.md b/rfc/0009-supervisor-middleware/README.md index 1f6eacde7b..8c311431cc 100644 --- a/rfc/0009-supervisor-middleware/README.md +++ b/rfc/0009-supervisor-middleware/README.md @@ -286,7 +286,7 @@ message HttpRequestResult { The evaluation and result are shaped so middleware composes cleanly in a chain. The allow/deny decision is a first-class result field rather than being mixed into content. If `has_body` is true, the transformed content a middleware returns (`HttpRequestResult.body`) becomes the request body the next middleware receives as `HttpRequestEvaluation.body`; if `has_body` is false, the supervisor keeps the previous body. The supervisor also feeds allowed header mutations into the next stage, so a chain is effectively a fold over a single request representation; a `deny` from any stage short-circuits the rest. See [Middleware ordering](#middleware-ordering) for how chains are assembled and ordered. -Headers use a repeated representation so duplicate lines and wire order survive evaluation and chaining. Before an external call, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. A result may return ordered writes and removals. Writes support append, overwrite, and skip modes but may target only the `x-openshell-middleware-*` namespace. Removals may target other headers visible to middleware, except credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. Header values containing control characters are invalid. OpenShell validates and applies a stage's mutations atomically. If any mutation is invalid, none are applied and the stage follows its configured `on_error` behavior. +Headers use a repeated representation so duplicate lines and wire order survive evaluation and chaining. Before an external call, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. A result may return ordered writes and removals for visible end-to-end fields without a required prefix. Writes support append, overwrite, and skip modes. Credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers remain protected. Header values containing control characters are invalid. OpenShell validates and applies a stage's mutations atomically. If any mutation is invalid, none are applied and the stage follows its configured `on_error` behavior. > **Update in PR #2477 - WebSocket middleware:** The following contract text adds the bidirectional `EvaluateWebSocketSession` RPC, WebSocket preflight, message limits, and the WebSocket binding for the built-in regex middleware. The unary HTTP contract does not change. @@ -525,7 +525,7 @@ This section closes the current review themes. - **Route selection and forwarding.** V1 has no `forward_to` decision. Middleware never makes the upstream call. Future route-selection hooks may choose among OpenShell-managed destinations, such as model routes, but must not become arbitrary external endpoint rewrites. - **SigV4/request signing.** AWS SigV4 belongs to a restricted built-in `HttpRequest/post_credentials` hook, not external `HttpRequest/pre_credentials` middleware. The middleware can be configured by policy, but it must run in-process with supervisor host capabilities so it can strip placeholder signatures and sign with real supervisor-resolved credentials without exposing those credentials over the external middleware contract. - **Composability and ordering.** Middleware is chainable and ordered by ascending numeric `order`. Order values must be unique across the policy. A stage receives the previous stage's transformed body and header mutations; `deny` short-circuits the chain; and different config map keys may invoke the same binding as separate stages. -- **Header mutation.** Headers preserve duplicates and wire order. External writes are limited to `x-openshell-middleware-*` and support append, overwrite, or skip. Removes may target other visible headers. Credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers remain protected. Each stage's mutations are atomic. +- **Header mutation.** Headers preserve duplicates and wire order. External writes and removals may target visible end-to-end headers without a required prefix. Writes support append, overwrite, or skip. Credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers remain protected. Each stage's mutations are atomic. - **Finding shape.** Findings never include matched values or raw content. Built-ins may provide contract-defined audit-safe labels. Operator-run text and metadata are untrusted and are replaced or omitted in security outputs in favor of validated binding IDs, platform labels, and aggregate counts. - **Actor data.** Actor process data is optional and per-connection. Middleware must treat it as context, not a reliable per-request identity or authorization input. - **Metadata namespacing.** Metadata is stored under the policy-local middleware config map key rather than the optional human-readable name. This prevents collisions without a central key registry and lets two configs using the same implementation emit independent metadata. diff --git a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md index 63115d9e52..d8ae31bdba 100644 --- a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md +++ b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md @@ -72,7 +72,7 @@ A future version can introduce named feature contracts, such as `pii-redaction`, ## Header mutation rules -V1 preserves duplicate request headers and their wire order. Before an external invocation, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. Results return ordered `write` and `remove` mutations. Writes support append, overwrite, and skip modes but may target only `x-openshell-middleware-*`. Removes may target other visible headers except the protected categories. OpenShell validates and applies a stage's mutations atomically, so one invalid mutation discards the whole set and follows that config's `on_error` behavior. +V1 preserves duplicate request headers and their wire order. Before an external invocation, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. Results return ordered `write` and `remove` mutations for visible end-to-end fields without a required prefix. Writes support append, overwrite, and skip modes. Credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers remain protected. OpenShell validates and applies a stage's mutations atomically, so one invalid mutation discards the whole set and follows that config's `on_error` behavior. ## Middleware authentication From 2250e405455f6e4650093bd6483662a639d1794d Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 31 Aug 2026 19:12:42 -0700 Subject: [PATCH 2/6] feat(middleware): add HTTP response pre-return runtime Add the finalized bidirectional response protocol, separate gRPC service transport, manifest binding, and ordered headers/whole-body/streaming/trailer session runner.\n\nRefs #2691 Signed-off-by: Piotr Mlocek --- crates/openshell-core/src/middleware.rs | 33 +- .../src/headers.rs | 8 +- .../src/lib.rs | 46 +- .../src/remote.rs | 36 +- .../src/response.rs | 2001 +++++++++++++++++ proto/supervisor_middleware.proto | 153 +- 6 files changed, 2260 insertions(+), 17 deletions(-) create mode 100644 crates/openshell-supervisor-middleware/src/response.rs diff --git a/crates/openshell-core/src/middleware.rs b/crates/openshell-core/src/middleware.rs index 2b3fb18982..d1d59f2a8c 100644 --- a/crates/openshell-core/src/middleware.rs +++ b/crates/openshell-core/src/middleware.rs @@ -11,11 +11,17 @@ use tokio::sync::mpsc; use tonic::{Request, Response, Status}; use crate::proto::{ - HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, MiddlewareManifest, - RequestContext, SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, - WebSocketSessionEvent, WebSocketSessionEventResult, + HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, HttpResponseEvent, + HttpResponseEventResult, MiddlewareManifest, RequestContext, SupervisorMiddlewarePhase, + ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, + WebSocketSessionEventResult, }; +/// Transport-neutral result stream for one HTTP response middleware stage. +pub type HttpResponseResultStream = Pin< + Box> + Send + 'static>, +>; + /// Transport-neutral response stream for one WebSocket middleware stage. pub type WebSocketResponseStream = Pin< Box< @@ -47,6 +53,15 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync { &self, requests: mpsc::Receiver, ) -> Result; + + async fn open_http_response_pre_return( + &self, + _requests: mpsc::Receiver, + ) -> Result { + Err(Status::unimplemented( + "middleware does not implement HTTP response pre-return evaluation", + )) + } } /// Borrowed request state exposed to one in-process middleware invocation. @@ -242,6 +257,18 @@ pub trait InProcessMiddleware: Send + Sync { "middleware does not implement WebSocket sessions", )) } + + /// Open one HTTP response pre-return stream. + /// + /// Request-only implementations may keep the default unsupported response. + async fn open_http_response_pre_return( + &self, + _requests: mpsc::Receiver, + ) -> std::result::Result { + Err(Status::unimplemented( + "middleware does not implement HTTP response pre-return evaluation", + )) + } } /// Default timeout for one supervisor middleware RPC. diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs index 283c47cc53..ba57ba7de0 100644 --- a/crates/openshell-supervisor-middleware/src/headers.rs +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -13,6 +13,7 @@ pub const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024; pub enum HeaderAuthority { Request, Response, + ResponseTrailers, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -122,7 +123,7 @@ pub fn apply( for mutation in mutations { match mutation.operation.as_ref() { Some(header_mutation::Operation::Write(write)) => { - let name = validate_name(&write.name)?; + let name = normalize_name(&write.name)?; validate_authority(authority, MutationKind::Write, &write.name, &name)?; if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { @@ -163,7 +164,7 @@ pub fn apply( } } Some(header_mutation::Operation::Remove(remove)) => { - let name = validate_name(&remove.name)?; + let name = normalize_name(&remove.name)?; validate_authority(authority, MutationKind::Remove, &remove.name, &name)?; if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { @@ -187,7 +188,7 @@ fn enforce_size_limit(mutation_bytes: usize) -> Result<(), HeaderMutationError> Ok(()) } -fn validate_name(name: &str) -> Result { +pub fn normalize_name(name: &str) -> Result { let lower = name.to_ascii_lowercase(); if lower.is_empty() || !lower.bytes().all(is_name_token_byte) { return Err(HeaderMutationError::InvalidName { @@ -215,6 +216,7 @@ fn validate_authority( is_response_protected(normalized_name) || (kind == MutationKind::Write && is_response_remove_only(normalized_name)) } + HeaderAuthority::ResponseTrailers => is_response_protected(normalized_name), }; if protected { return Err(HeaderMutationError::Protected { diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index bb27d26270..0d4b3a638c 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -5,8 +5,15 @@ pub mod headers; mod remote; +mod response; mod websocket; +pub use response::{ + HttpResponseFinish, HttpResponseInvocation, HttpResponseInvocationOutcome, + HttpResponseMiddlewareFailure, HttpResponsePreflightInput, HttpResponsePreflightOutcome, + HttpResponseSession, MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES, +}; + pub use websocket::{ WebSocketCoverage, WebSocketCoverageState, WebSocketInvocation, WebSocketInvocationOutcome, WebSocketMessageAdmission, WebSocketMessageOutcome, WebSocketMessageType, @@ -33,7 +40,8 @@ use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore}; use tonic::{Request, Response as TonicResponse, Status as TonicStatus}; pub use openshell_core::middleware::{ - HttpRequestView, InProcessMiddleware, SupervisorMiddlewareEndpoint, WebSocketResponseStream, + HttpRequestView, HttpResponseResultStream, InProcessMiddleware, SupervisorMiddlewareEndpoint, + WebSocketResponseStream, }; pub type MiddlewareService = dyn SupervisorMiddleware; @@ -180,6 +188,13 @@ impl InProcessMiddleware for EndpointInProcessAdapter { ) -> std::result::Result { self.endpoint.open_websocket_session(requests).await } + + async fn open_http_response_pre_return( + &self, + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + self.endpoint.open_http_response_pre_return(requests).await + } } /// Adapt a transport-neutral endpoint to the in-process registry contract. @@ -618,6 +633,16 @@ impl MiddlewareDispatch { Self::Grpc(service) => service.open_websocket_session(receiver).await, } } + + async fn open_http_response_pre_return( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + match self { + Self::InProcess(service) => service.open_http_response_pre_return(receiver).await, + Self::Grpc(service) => service.open_http_response_pre_return(receiver).await, + } + } } struct MiddlewareServiceState { @@ -823,6 +848,7 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result Result Ok(SupportedBinding::HttpPreCredentials), + ( + Some(SupervisorMiddlewareOperation::HttpResponse), + Some(SupervisorMiddlewarePhase::PreReturn), + ) => Ok(SupportedBinding::HttpResponsePreReturn), ( Some(SupervisorMiddlewareOperation::WebsocketMessage), Some(SupervisorMiddlewarePhase::PreCredentials), @@ -1435,6 +1465,20 @@ impl ChainRunner { .entries) } + pub async fn describe_http_response_chain( + &self, + entries: &[ChainEntry], + ) -> Result> { + Ok(self + .describe_chain_for( + entries, + SupervisorMiddlewareOperation::HttpResponse, + SupervisorMiddlewarePhase::PreReturn, + ) + .await? + .entries) + } + async fn describe_chain_for( &self, entries: &[ChainEntry], diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index edc1e8066c..80049b69dc 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -3,12 +3,14 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::middleware::{ - HttpRequestView, SupervisorMiddlewareEndpoint, WebSocketResponseStream, + HttpRequestView, HttpResponseResultStream, SupervisorMiddlewareEndpoint, + WebSocketResponseStream, }; +use openshell_core::proto::middleware::v1::http_response_pre_return_client::HttpResponsePreReturnClient; use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; use openshell_core::proto::{ - HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, - ValidateConfigResponse, WebSocketSessionEvent, + HttpRequestEvaluation, HttpRequestResult, HttpResponseEvent, MiddlewareManifest, + ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, }; use openshell_extension_core::{ BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust, @@ -101,11 +103,20 @@ impl GrpcMiddlewareService { ) -> std::result::Result { self.service.open_websocket_session(receiver).await } + + /// Open a remote HTTP response pre-return stream through the gRPC adapter. + pub async fn open_http_response_pre_return( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + self.service.open_http_response_pre_return(receiver).await + } } #[derive(Clone)] pub struct RemoteMiddlewareService { client: SupervisorMiddlewareClient, + response_client: HttpResponsePreReturnClient, } impl RemoteMiddlewareService { @@ -133,7 +144,10 @@ impl RemoteMiddlewareService { let channel = InterceptedService::new(channel, interceptor); Ok(Self { - client: SupervisorMiddlewareClient::new(channel) + client: SupervisorMiddlewareClient::new(channel.clone()) + .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) + .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), + response_client: HttpResponsePreReturnClient::new(channel) .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), }) @@ -179,4 +193,18 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService { .into_inner(); Ok(Box::pin(responses)) } + + async fn open_http_response_pre_return( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let mut client = self.response_client.clone(); + let responses = client + .evaluate(Request::new(tokio_stream::wrappers::ReceiverStream::new( + receiver, + ))) + .await? + .into_inner(); + Ok(Box::pin(responses)) + } } diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs new file mode 100644 index 0000000000..7c9f4ce5e2 --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -0,0 +1,2001 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! HTTP response pre-return middleware chain execution. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::Duration; + +use futures::StreamExt as _; +use prost::Message as _; +use tokio::sync::mpsc; +use tokio::time::Instant; + +use openshell_core::proto::{ + Finding, HeaderMutation, HttpHeader, HttpRequestTarget, HttpResponseBodyEnd, + HttpResponseBodyMode, HttpResponseBodyPassThrough, HttpResponseBodyUnit, HttpResponseEvent, + HttpResponseEventResult, HttpResponsePreflight, HttpResponseSessionEnd, + HttpResponseSessionEndReason, HttpResponseTrailers, RemoveHeader, RequestContext, + header_mutation, http_response_body_result, http_response_body_transform, + http_response_body_unit, http_response_event, http_response_event_result, + http_response_preflight_decision, +}; + +use super::{ + ChainEntry, ChainRunner, DescribedChainEntry, MAX_MIDDLEWARE_CHAIN_TIMEOUT, + MAX_MIDDLEWARE_CONTEXT_BYTES, MAX_MIDDLEWARE_FINDING_BYTES, MAX_MIDDLEWARE_FINDINGS_PER_STAGE, + MAX_MIDDLEWARE_HEADER_BYTES, MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES, MAX_MIDDLEWARE_HEADERS, + MAX_MIDDLEWARE_METADATA_BYTES, MAX_MIDDLEWARE_METADATA_ENTRIES, + MAX_MIDDLEWARE_PREFLIGHT_TIMEOUT, MAX_MIDDLEWARE_REASON_BYTES, + MAX_MIDDLEWARE_REASON_CODE_BYTES, MAX_MIDDLEWARE_TARGET_BYTES, MiddlewareDiagnosticPolicy, + MiddlewareSessionAdmission, MiddlewareSessionPermit, NamespacedFinding, OnError, headers, + is_stable_reason_code, +}; + +const STREAM_CHANNEL_CAPACITY: usize = 4; +pub const MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone)] +pub struct HttpResponsePreflightInput { + pub context: RequestContext, + pub target: HttpRequestTarget, + pub status_code: u16, + /// Parsed upstream Content-Length when present and valid. + pub declared_body_length: Option, + /// Sanitized, lowercased final response headers in wire order. + pub headers: Vec, + /// Lowercased names nominated by the original response's `Connection` + /// fields. Their values are not exposed to middleware. + pub connection_nominated_headers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HttpResponseInvocationOutcome { + Skip, + HeadersOnly, + WholeBody, + Stream, + PassThrough, + Transform, + FailOpen, + FailClosed, +} + +#[derive(Debug, Clone)] +pub struct HttpResponseInvocation { + pub config_name: String, + pub implementation: String, + pub outcome: HttpResponseInvocationOutcome, + pub sequence: Option, + pub input_size: usize, + pub output_size: Option, + pub failed: bool, + pub stage_disabled: bool, + pub reason_code: Option, +} + +pub struct HttpResponsePreflightOutcome { + pub allowed: bool, + pub reason: String, + pub headers: Vec, + pub declared_trailer_names: Vec, + pub session: Option, + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, + pub session_capacity_exhausted: bool, +} + +#[derive(Debug)] +pub struct HttpResponseMiddlewareFailure { + pub reason: String, +} + +impl std::fmt::Display for HttpResponseMiddlewareFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.reason) + } +} + +impl std::error::Error for HttpResponseMiddlewareFailure {} + +#[derive(Debug)] +pub struct HttpResponseFinish { + /// Units released while whole-body stages were finalized. + pub body_units: Vec>, + pub trailers: Vec, + /// True when a whole-body stage transformed or deleted body bytes. The + /// caller must strip stale representation validators before commitment. + pub strip_stale_integrity_headers: bool, + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, +} + +struct HttpResponseStageTransport { + sender: mpsc::Sender, + responses: super::HttpResponseResultStream, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StageMode { + HeadersOnly, + WholeBody, + Stream, +} + +struct HttpResponseStage { + entry: DescribedChainEntry, + transport: Option, + mode: StageMode, + next_sequence: u64, + declared_trailer_names: BTreeSet, + whole_body: Vec, +} + +impl HttpResponseStage { + fn is_active(&self) -> bool { + self.transport.is_some() + } + + fn is_body_active(&self) -> bool { + self.is_active() && self.mode != StageMode::HeadersOnly + } + + async fn end(&mut self, reason: HttpResponseSessionEndReason) { + if let Some(transport) = self.transport.take() { + let _ = tokio::time::timeout( + Duration::from_millis(10), + transport.sender.send(session_end_event(reason)), + ) + .await; + } + } +} + +pub struct HttpResponseSession { + runner: ChainRunner, + stages: Vec, + connection_nominated_headers: Vec, + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, + session_admission: Option, + body_transformed: bool, +} + +impl HttpResponseSession { + #[must_use] + pub fn requires_whole_body(&self) -> bool { + self.stages + .iter() + .any(|stage| stage.is_active() && stage.mode == StageMode::WholeBody) + } + + #[must_use] + pub fn stream_unit_limit(&self) -> usize { + self.stages + .iter() + .filter(|stage| stage.is_active() && stage.mode == StageMode::Stream) + .map(|stage| { + stage + .entry + .max_payload_bytes + .min(MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES) + }) + .min() + .unwrap_or(MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES) + } + + /// Process one normalized body unit through the active chain. + /// + /// The caller must provide no more than [`Self::stream_unit_limit`] bytes. + /// A whole-body barrier retains output until [`Self::finish`] is called. + pub async fn push_body( + &mut self, + data: Vec, + ) -> Result>, HttpResponseMiddlewareFailure> { + if data.len() > self.stream_unit_limit() { + return Err(HttpResponseMiddlewareFailure { + reason: "response_stream_unit_over_capacity".into(), + }); + } + let _work = self + .runner + .reserve_middleware_work_admission() + .await + .map_err(|error| HttpResponseMiddlewareFailure { + reason: format!("middleware_failed: {error}"), + })?; + let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + self.process_units_from(0, vec![data], deadline).await + } + + /// Finalize every body stage, process normalized trailers, and end streams. + pub async fn finish( + mut self, + trailers: Vec, + ) -> Result { + let _work = self + .runner + .reserve_middleware_work_admission() + .await + .map_err(|error| HttpResponseMiddlewareFailure { + reason: format!("middleware_failed: {error}"), + })?; + let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + let mut released = Vec::new(); + for index in 0..self.stages.len() { + let stage_output = match self.finish_stage(index, deadline).await { + Ok(output) => output, + Err(failure) => { + self.end_all(HttpResponseSessionEndReason::MiddlewareFailure) + .await; + return Err(failure); + } + }; + if !stage_output.is_empty() { + let output = self + .process_units_from(index + 1, stage_output, deadline) + .await?; + released.extend(output); + } + } + + let trailers = match self.process_trailers(trailers, deadline).await { + Ok(trailers) => trailers, + Err(failure) => { + self.end_all(HttpResponseSessionEndReason::MiddlewareFailure) + .await; + return Err(failure); + } + }; + self.end_all(HttpResponseSessionEndReason::Normal).await; + self.session_admission.take(); + Ok(HttpResponseFinish { + body_units: released, + trailers, + strip_stale_integrity_headers: self.body_transformed, + findings: self.findings, + metadata: self.metadata, + invocations: self.invocations, + }) + } + + pub async fn end(mut self, reason: HttpResponseSessionEndReason) { + self.end_all(reason).await; + } + + async fn process_units_from( + &mut self, + start: usize, + mut units: Vec>, + deadline: Instant, + ) -> Result>, HttpResponseMiddlewareFailure> { + for index in start..self.stages.len() { + let mut next = Vec::new(); + for unit in units { + let chunk_limit = if self.stages[index].mode == StageMode::Stream { + self.stages[index] + .entry + .max_payload_bytes + .min(MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES) + } else { + unit.len().max(1) + }; + if unit.is_empty() { + next.extend(self.process_stage_unit(index, unit, deadline).await?); + } else { + for chunk in unit.chunks(chunk_limit) { + next.extend( + self.process_stage_unit(index, chunk.to_vec(), deadline) + .await?, + ); + } + } + } + units = next; + if units.is_empty() + && self.stages[index + 1..] + .iter() + .all(|stage| stage.mode != StageMode::WholeBody) + { + break; + } + } + Ok(units) + } + + async fn process_stage_unit( + &mut self, + index: usize, + data: Vec, + deadline: Instant, + ) -> Result>, HttpResponseMiddlewareFailure> { + let stage = &mut self.stages[index]; + if !stage.is_active() || stage.mode == StageMode::HeadersOnly { + return Ok(vec![data]); + } + if stage.mode == StageMode::WholeBody { + if stage.whole_body.len().saturating_add(data.len()) > stage.entry.max_payload_bytes { + let mut original = std::mem::take(&mut stage.whole_body); + original.extend_from_slice(&data); + return self + .handle_stage_failure(index, "whole_body_over_capacity", None, original) + .await; + } + stage.whole_body.extend_from_slice(&data); + return Ok(Vec::new()); + } + + let sequence = stage.next_sequence; + stage.next_sequence += 1; + let input_size = data.len(); + let event = body_event(sequence, data.clone()); + let result = match exchange(stage, event, deadline).await { + Ok(result) => result, + Err(reason) => { + return self + .handle_stage_failure(index, &reason, Some(sequence), data) + .await; + } + }; + match validate_body_result(result, sequence, stage.entry.max_payload_bytes) { + Ok(BodyDecision::PassThrough(findings, metadata)) => { + collect_diagnostics( + stage, + findings, + metadata, + &mut self.findings, + &mut self.metadata, + ); + self.invocations.push(body_invocation( + stage, + HttpResponseInvocationOutcome::PassThrough, + sequence, + input_size, + input_size, + )); + Ok(vec![data]) + } + Ok(BodyDecision::Transform(replacement, findings, metadata)) => { + self.body_transformed = true; + collect_diagnostics( + stage, + findings, + metadata, + &mut self.findings, + &mut self.metadata, + ); + self.invocations.push(body_invocation( + stage, + HttpResponseInvocationOutcome::Transform, + sequence, + input_size, + replacement.len(), + )); + if replacement.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![replacement]) + } + } + Err(reason) => { + self.handle_stage_failure(index, reason, Some(sequence), data) + .await + } + } + } + + async fn finish_stage( + &mut self, + index: usize, + deadline: Instant, + ) -> Result>, HttpResponseMiddlewareFailure> { + let stage = &mut self.stages[index]; + if !stage.is_body_active() { + return Ok(Vec::new()); + } + let mut output = Vec::new(); + if stage.mode == StageMode::WholeBody { + let data = std::mem::take(&mut stage.whole_body); + let sequence = 1; + stage.next_sequence = 2; + let input_size = data.len(); + let result = match exchange(stage, body_event(sequence, data.clone()), deadline).await { + Ok(result) => result, + Err(reason) => { + return self + .handle_stage_failure(index, &reason, Some(sequence), data) + .await; + } + }; + match validate_body_result(result, sequence, stage.entry.max_payload_bytes) { + Ok(BodyDecision::PassThrough(findings, metadata)) => { + collect_diagnostics( + stage, + findings, + metadata, + &mut self.findings, + &mut self.metadata, + ); + self.invocations.push(body_invocation( + stage, + HttpResponseInvocationOutcome::PassThrough, + sequence, + input_size, + input_size, + )); + output.push(data); + } + Ok(BodyDecision::Transform(replacement, findings, metadata)) => { + self.body_transformed = true; + collect_diagnostics( + stage, + findings, + metadata, + &mut self.findings, + &mut self.metadata, + ); + self.invocations.push(body_invocation( + stage, + HttpResponseInvocationOutcome::Transform, + sequence, + input_size, + replacement.len(), + )); + if !replacement.is_empty() { + output.push(replacement); + } + } + Err(reason) => { + return self + .handle_stage_failure(index, reason, Some(sequence), data) + .await; + } + } + } + + let final_sequence = stage.next_sequence.saturating_sub(1); + let body_end_sent = if let Some(transport) = stage.transport.as_ref() { + send_without_result( + &transport.sender, + HttpResponseEvent { + event: Some(http_response_event::Event::BodyEnd(HttpResponseBodyEnd { + final_sequence, + })), + }, + deadline, + stage.entry.timeout, + ) + .await + .is_ok() + } else { + false + }; + if !body_end_sent { + self.handle_stage_failure(index, "middleware_stream_closed", None, Vec::new()) + .await?; + } + Ok(output) + } + + async fn process_trailers( + &mut self, + mut trailers: Vec, + deadline: Instant, + ) -> Result, HttpResponseMiddlewareFailure> { + if self.body_transformed { + strip_stale_integrity(&mut trailers); + } + for index in 0..self.stages.len() { + if !self.stages[index].is_body_active() { + continue; + } + let event = HttpResponseEvent { + event: Some(http_response_event::Event::Trailers(HttpResponseTrailers { + headers: trailers.clone(), + })), + }; + let result = match exchange(&mut self.stages[index], event, deadline).await { + Ok(result) => result, + Err(reason) => { + let original = Vec::new(); + self.handle_stage_failure(index, &reason, None, original) + .await?; + continue; + } + }; + let Some(http_response_event_result::Result::TrailersResult(trailer_result)) = + result.result + else { + self.handle_stage_failure(index, "unexpected_response_result", None, Vec::new()) + .await?; + continue; + }; + if let Err(reason) = validate_diagnostics( + &trailer_result.reason, + "", + &trailer_result.findings, + &trailer_result.metadata, + ) { + self.handle_stage_failure(index, reason, None, Vec::new()) + .await?; + continue; + } + if let Err(reason) = + validate_trailer_mutations(&self.stages[index], &trailer_result.trailer_mutations) + { + self.handle_stage_failure(index, reason, None, Vec::new()) + .await?; + continue; + } + match headers::apply( + headers::HeaderAuthority::ResponseTrailers, + &trailers, + &self.connection_nominated_headers, + &trailer_result.trailer_mutations, + ) { + Ok(updated) => trailers = updated, + Err(error) => { + let reason = self.stages[index].entry.service.as_ref().map_or_else( + || error.to_string(), + |service| { + service + .diagnostic_policy + .header_mutation_error_reason(&error) + }, + ); + self.handle_stage_failure(index, &reason, None, Vec::new()) + .await?; + continue; + } + } + let findings = trailer_result.findings; + let metadata = trailer_result.metadata; + collect_diagnostics( + &self.stages[index], + findings, + metadata, + &mut self.findings, + &mut self.metadata, + ); + } + Ok(trailers) + } + + async fn handle_stage_failure( + &mut self, + index: usize, + reason: &str, + sequence: Option, + original: Vec, + ) -> Result>, HttpResponseMiddlewareFailure> { + let stage = &mut self.stages[index]; + let outcome = if stage.entry.on_error() == OnError::FailOpen { + HttpResponseInvocationOutcome::FailOpen + } else { + HttpResponseInvocationOutcome::FailClosed + }; + self.invocations.push(HttpResponseInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome, + sequence, + input_size: original.len(), + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + }); + stage + .end(HttpResponseSessionEndReason::MiddlewareFailure) + .await; + if stage.entry.on_error() == OnError::FailOpen { + if original.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![original]) + } + } else { + Err(HttpResponseMiddlewareFailure { + reason: format!("middleware_failed: {reason}"), + }) + } + } + + async fn end_all(&mut self, reason: HttpResponseSessionEndReason) { + for stage in &mut self.stages { + stage.end(reason).await; + } + } +} + +impl ChainRunner { + pub async fn preflight_http_response( + &self, + entries: &[ChainEntry], + input: HttpResponsePreflightInput, + ) -> miette::Result { + validate_preflight_input(&input)?; + let described = self.describe_http_response_chain(entries).await?; + if described.is_empty() { + return Ok(empty_preflight_outcome(input.headers)); + } + let session_admission = match self.try_reserve_middleware_session() { + MiddlewareSessionAdmission::Admitted(admission) => admission, + MiddlewareSessionAdmission::AtCapacity => { + return Ok(response_session_capacity_exhausted( + described, + input.headers, + )); + } + }; + let _work = self.reserve_middleware_work_admission().await?; + let original_restriction = body_restriction(&input); + let mut headers = input.headers.clone(); + let mut stages = Vec::new(); + let mut findings = Vec::new(); + let mut metadata = BTreeMap::new(); + let mut invocations = Vec::new(); + let mut declared_trailer_names = BTreeSet::new(); + + for entry in described { + let Some(service) = entry.service.as_ref() else { + if let Some(reason) = + collect_preflight_failure(&entry, "binding_not_described", &mut invocations) + { + end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure).await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + }; + let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let preflight = HttpResponsePreflight { + context: Some(input.context.clone()), + target: Some(input.target.clone()), + status_code: u32::from(input.status_code), + headers: headers.clone(), + middleware_name: entry.entry.implementation.clone(), + config: Some(entry.entry.config.clone()), + max_payload_bytes: entry.max_payload_bytes as u64, + }; + let timeout = entry.timeout.min(MAX_MIDDLEWARE_PREFLIGHT_TIMEOUT); + let opened = tokio::time::timeout(timeout, async { + let mut responses = service + .service + .open_http_response_pre_return(receiver) + .await?; + sender + .send(HttpResponseEvent { + event: Some(http_response_event::Event::Preflight(preflight)), + }) + .await + .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; + let response = responses.next().await.ok_or_else(|| { + tonic::Status::unavailable("middleware result stream closed") + })??; + Ok::<_, tonic::Status>((responses, response)) + }) + .await; + let (responses, response) = match opened { + Ok(Ok(opened)) => opened, + Ok(Err(error)) => { + let reason = if error.code() == tonic::Code::DeadlineExceeded { + "middleware_timeout".to_string() + } else { + service.diagnostic_policy.error_reason(&error) + }; + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + Err(_) => { + if let Some(reason) = + collect_preflight_failure(&entry, "middleware_timeout", &mut invocations) + { + end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + let Some(http_response_event_result::Result::PreflightDecision(decision)) = + response.result + else { + if let Some(reason) = collect_preflight_failure( + &entry, + "unexpected_response_result", + &mut invocations, + ) { + end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure).await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + }; + match decision.decision { + Some(http_response_preflight_decision::Decision::Skip(skip)) => { + let invalid = validate_diagnostics( + &skip.reason, + &skip.reason_code, + &skip.findings, + &skip.metadata, + ); + if let Err(reason) = invalid { + if let Some(reason) = + collect_preflight_failure(&entry, reason, &mut invocations) + { + end_stages( + &mut stages, + HttpResponseSessionEndReason::MiddlewareFailure, + ) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + collect_preflight_diagnostics( + &entry, + skip.findings, + skip.metadata, + &mut findings, + &mut metadata, + ); + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: HttpResponseInvocationOutcome::Skip, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code: (!skip.reason_code.is_empty()).then_some(skip.reason_code), + }); + let mut skipped = HttpResponseStage { + entry, + transport: Some(HttpResponseStageTransport { sender, responses }), + mode: StageMode::HeadersOnly, + next_sequence: 1, + declared_trailer_names: BTreeSet::new(), + whole_body: Vec::new(), + }; + skipped + .end(HttpResponseSessionEndReason::StageSkipped) + .await; + } + Some(http_response_preflight_decision::Decision::Inspect(inspect)) => { + let mode = match validate_inspect( + &entry, + &inspect, + original_restriction.as_deref(), + input.declared_body_length, + &input.connection_nominated_headers, + ) { + Ok(mode) => mode, + Err(reason) => { + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages( + &mut stages, + HttpResponseSessionEndReason::MiddlewareFailure, + ) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + let updated = match headers::apply( + headers::HeaderAuthority::Response, + &headers, + &input.connection_nominated_headers, + &inspect.header_mutations, + ) { + Ok(updated) => updated, + Err(error) => { + let reason = service + .diagnostic_policy + .header_mutation_error_reason(&error); + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages( + &mut stages, + HttpResponseSessionEndReason::MiddlewareFailure, + ) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + headers = updated; + if mode == StageMode::Stream { + strip_stale_integrity(&mut headers); + } + let declared = normalize_declared_trailers( + &inspect.declared_trailer_names, + &input.connection_nominated_headers, + ) + .expect("inspect validation normalized trailer declarations"); + declared_trailer_names.extend(declared.iter().cloned()); + collect_preflight_diagnostics( + &entry, + inspect.findings, + inspect.metadata, + &mut findings, + &mut metadata, + ); + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: match mode { + StageMode::HeadersOnly => HttpResponseInvocationOutcome::HeadersOnly, + StageMode::WholeBody => HttpResponseInvocationOutcome::WholeBody, + StageMode::Stream => HttpResponseInvocationOutcome::Stream, + }, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code: None, + }); + stages.push(HttpResponseStage { + entry, + transport: Some(HttpResponseStageTransport { sender, responses }), + mode, + next_sequence: 1, + declared_trailer_names: declared, + whole_body: Vec::new(), + }); + } + None => { + if let Some(reason) = collect_preflight_failure( + &entry, + "invalid_preflight_decision", + &mut invocations, + ) { + end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + } + } + } + + if stages.is_empty() { + drop(session_admission); + return Ok(HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + headers, + declared_trailer_names: declared_trailer_names.into_iter().collect(), + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }); + } + Ok(HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + headers, + declared_trailer_names: declared_trailer_names.into_iter().collect(), + session: Some(HttpResponseSession { + runner: self.clone(), + stages, + connection_nominated_headers: input.connection_nominated_headers, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + session_admission: Some(session_admission), + body_transformed: false, + }), + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }) + } +} + +enum BodyDecision { + PassThrough(Vec, std::collections::HashMap), + Transform( + Vec, + Vec, + std::collections::HashMap, + ), +} + +fn validate_body_result( + result: HttpResponseEventResult, + sequence: u64, + max_payload_bytes: usize, +) -> Result { + let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { + return Err("unexpected_response_result"); + }; + if body.sequence != sequence { + return Err("response_body_sequence_mismatch"); + } + validate_diagnostics(&body.reason, "", &body.findings, &body.metadata)?; + match body.decision { + Some(http_response_body_result::Decision::PassThrough(HttpResponseBodyPassThrough {})) => { + Ok(BodyDecision::PassThrough(body.findings, body.metadata)) + } + Some(http_response_body_result::Decision::Transform(transform)) => { + let Some(http_response_body_transform::Replacement::Data(replacement)) = + transform.replacement + else { + return Err("response_body_replacement_missing"); + }; + if replacement.len() > max_payload_bytes { + return Err("response_body_replacement_over_capacity"); + } + Ok(BodyDecision::Transform( + replacement, + body.findings, + body.metadata, + )) + } + None => Err("invalid_response_body_decision"), + } +} + +fn validate_inspect( + entry: &DescribedChainEntry, + inspect: &openshell_core::proto::HttpResponsePreflightInspect, + body_restriction: Option<&str>, + declared_body_length: Option, + connection_nominated_headers: &[String], +) -> Result { + validate_diagnostics(&inspect.reason, "", &inspect.findings, &inspect.metadata) + .map_err(str::to_string)?; + let mode = match HttpResponseBodyMode::try_from(inspect.body_mode) { + Ok(HttpResponseBodyMode::HeadersOnly) => StageMode::HeadersOnly, + Ok(HttpResponseBodyMode::WholeBodyBytes) => StageMode::WholeBody, + Ok(HttpResponseBodyMode::StreamBytes) => StageMode::Stream, + Ok(HttpResponseBodyMode::Unspecified) | Err(_) => { + return Err("invalid_response_body_mode".into()); + } + }; + if mode != StageMode::HeadersOnly + && let Some(restriction) = body_restriction + { + return Err(restriction.to_string()); + } + if mode == StageMode::WholeBody + && declared_body_length.is_some_and(|length| length > entry.max_payload_bytes as u64) + { + return Err("whole_body_over_capacity".into()); + } + if mode == StageMode::HeadersOnly && !inspect.declared_trailer_names.is_empty() { + return Err("response_trailer_declaration_without_body".into()); + } + if inspect + .header_mutations + .len() + .saturating_add(inspect.declared_trailer_names.len()) + > headers::MAX_HEADER_MUTATIONS + { + return Err("header_mutation_count_over_capacity".into()); + } + let encoded_mutations = inspect + .header_mutations + .iter() + .fold(0usize, |total, mutation| { + total.saturating_add(mutation.encoded_len()) + }); + let declared_bytes = inspect + .declared_trailer_names + .iter() + .fold(0usize, |total, name| total.saturating_add(name.len())); + if encoded_mutations.saturating_add(declared_bytes) > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES + { + return Err("header_mutation_bytes_over_capacity".into()); + } + normalize_declared_trailers( + &inspect.declared_trailer_names, + connection_nominated_headers, + )?; + if entry.max_payload_bytes == 0 && mode != StageMode::HeadersOnly { + return Err("response_payload_limit_invalid".into()); + } + Ok(mode) +} + +fn normalize_declared_trailers( + names: &[String], + connection_nominated_headers: &[String], +) -> Result, String> { + let mut normalized = BTreeSet::new(); + for name in names { + let name = headers::normalize_name(name).map_err(|error| error.to_string())?; + let validation = HeaderMutation { + operation: Some(header_mutation::Operation::Remove(RemoveHeader { + name: name.clone(), + })), + }; + headers::apply( + headers::HeaderAuthority::ResponseTrailers, + &[], + connection_nominated_headers, + &[validation], + ) + .map_err(|error| error.to_string())?; + if !normalized.insert(name) { + return Err("response_trailer_declaration_duplicate".into()); + } + } + Ok(normalized) +} + +fn validate_trailer_mutations( + stage: &HttpResponseStage, + mutations: &[HeaderMutation], +) -> Result<(), &'static str> { + if mutations.len() > headers::MAX_HEADER_MUTATIONS { + return Err("header_mutation_count_over_capacity"); + } + if mutations.iter().fold(0usize, |total, mutation| { + total.saturating_add(mutation.encoded_len()) + }) > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES + { + return Err("header_mutation_bytes_over_capacity"); + } + for mutation in mutations { + if let Some(header_mutation::Operation::Write(write)) = mutation.operation.as_ref() { + let name = + headers::normalize_name(&write.name).map_err(|_| "header_mutation_invalid_name")?; + if !stage.declared_trailer_names.contains(&name) { + return Err("response_trailer_name_not_declared"); + } + } + } + Ok(()) +} + +fn validate_preflight_input(input: &HttpResponsePreflightInput) -> miette::Result<()> { + if input.context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { + return Err(miette::miette!("response context exceeds platform limit")); + } + if input.target.encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES { + return Err(miette::miette!("response target exceeds platform limit")); + } + if input.headers.len() > MAX_MIDDLEWARE_HEADERS { + return Err(miette::miette!( + "response header count exceeds platform limit" + )); + } + if input.headers.iter().fold(0usize, |total, header| { + total.saturating_add(header.encoded_len()) + }) > MAX_MIDDLEWARE_HEADER_BYTES + { + return Err(miette::miette!("response headers exceed platform limit")); + } + Ok(()) +} + +fn validate_diagnostics( + reason: &str, + reason_code: &str, + findings: &[Finding], + metadata: &std::collections::HashMap, +) -> Result<(), &'static str> { + if reason.len() > MAX_MIDDLEWARE_REASON_BYTES { + return Err("response_reason_over_capacity"); + } + if !reason_code.is_empty() + && (reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES + || !is_stable_reason_code(reason_code)) + { + return Err("response_reason_code_invalid"); + } + if findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { + return Err("response_findings_over_capacity"); + } + if findings + .iter() + .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) + { + return Err("response_finding_over_capacity"); + } + if metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { + return Err("response_metadata_count_over_capacity"); + } + if metadata.iter().fold(0usize, |total, (key, value)| { + total.saturating_add(key.len()).saturating_add(value.len()) + }) > MAX_MIDDLEWARE_METADATA_BYTES + { + return Err("response_metadata_bytes_over_capacity"); + } + Ok(()) +} + +fn body_restriction(input: &HttpResponsePreflightInput) -> Option { + if input.target.method.eq_ignore_ascii_case("HEAD") + || input.status_code == 204 + || input.status_code == 304 + { + return Some("bodyless_response".into()); + } + if input.status_code == 206 + || input + .headers + .iter() + .any(|header| header.name.eq_ignore_ascii_case("content-range")) + || input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-type") + && header + .value + .split(';') + .next() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("multipart/byteranges")) + }) + { + return Some("unsupported_partial_response".into()); + } + if input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("cache-control") + && header.value.split(',').any(|directive| { + directive + .split('=') + .next() + .is_some_and(|name| name.trim().eq_ignore_ascii_case("no-transform")) + }) + }) { + return Some("response_no_transform".into()); + } + if input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-encoding") + && header + .value + .split(',') + .any(|coding| !coding.trim().eq_ignore_ascii_case("identity")) + }) { + return Some("unsupported_content_encoding".into()); + } + None +} + +fn strip_stale_integrity(headers: &mut Vec) { + headers.retain(|header| { + !matches!( + header.name.to_ascii_lowercase().as_str(), + "accept-ranges" + | "etag" + | "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "signature" + | "signature-input" + ) + }); +} + +async fn exchange( + stage: &mut HttpResponseStage, + event: HttpResponseEvent, + chain_deadline: Instant, +) -> Result { + let remaining = chain_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("middleware_chain_timeout".into()); + } + let timeout = stage.entry.timeout.min(remaining); + let Some(transport) = stage.transport.as_mut() else { + return Err("middleware_stream_closed".into()); + }; + match tokio::time::timeout(timeout, async { + transport + .sender + .send(event) + .await + .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; + transport + .responses + .next() + .await + .ok_or_else(|| tonic::Status::unavailable("middleware result stream closed"))? + }) + .await + { + Ok(Ok(result)) => Ok(result), + Ok(Err(error)) => { + let policy = stage + .entry + .service + .as_ref() + .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { + service.diagnostic_policy + }); + Err(policy.error_reason(&error)) + } + Err(_) => Err("middleware_timeout".into()), + } +} + +async fn send_without_result( + sender: &mpsc::Sender, + event: HttpResponseEvent, + chain_deadline: Instant, + stage_timeout: Duration, +) -> Result<(), ()> { + let remaining = chain_deadline.saturating_duration_since(Instant::now()); + let timeout = stage_timeout.min(remaining); + tokio::time::timeout(timeout, sender.send(event)) + .await + .map_err(|_| ())? + .map_err(|_| ()) +} + +fn body_event(sequence: u64, data: Vec) -> HttpResponseEvent { + HttpResponseEvent { + event: Some(http_response_event::Event::Body(HttpResponseBodyUnit { + sequence, + payload: Some(http_response_body_unit::Payload::Data(data)), + })), + } +} + +fn session_end_event(reason: HttpResponseSessionEndReason) -> HttpResponseEvent { + HttpResponseEvent { + event: Some(http_response_event::Event::SessionEnd( + HttpResponseSessionEnd { + reason: reason as i32, + }, + )), + } +} + +fn body_invocation( + stage: &HttpResponseStage, + outcome: HttpResponseInvocationOutcome, + sequence: u64, + input_size: usize, + output_size: usize, +) -> HttpResponseInvocation { + HttpResponseInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome, + sequence: Some(sequence), + input_size, + output_size: Some(output_size), + failed: false, + stage_disabled: false, + reason_code: None, + } +} + +fn collect_diagnostics( + stage: &HttpResponseStage, + mut findings: Vec, + mut metadata: std::collections::HashMap, + all_findings: &mut Vec, + all_metadata: &mut BTreeMap>, +) { + if stage + .entry + .service + .as_ref() + .is_some_and(|service| service.diagnostic_policy == MiddlewareDiagnosticPolicy::Normalize) + { + metadata.clear(); + for finding in &mut findings { + finding.r#type = format!("{}.finding", stage.entry.entry.implementation); + finding.label = super::EXTERNAL_FINDING_LABEL.to_string(); + finding.confidence.clear(); + finding.severity = "medium".into(); + } + } + all_findings.extend(findings.into_iter().map(|finding| NamespacedFinding { + middleware: stage.entry.entry.name.clone(), + finding, + })); + if !metadata.is_empty() { + all_metadata.insert( + stage.entry.entry.name.clone(), + metadata.into_iter().collect(), + ); + } +} + +fn collect_preflight_diagnostics( + entry: &DescribedChainEntry, + findings: Vec, + metadata: std::collections::HashMap, + all_findings: &mut Vec, + all_metadata: &mut BTreeMap>, +) { + let stage = HttpResponseStage { + entry: entry.clone(), + transport: None, + mode: StageMode::HeadersOnly, + next_sequence: 1, + declared_trailer_names: BTreeSet::new(), + whole_body: Vec::new(), + }; + collect_diagnostics(&stage, findings, metadata, all_findings, all_metadata); +} + +fn collect_preflight_failure( + entry: &DescribedChainEntry, + reason: &str, + invocations: &mut Vec, +) -> Option { + let fail_closed = entry.on_error() == OnError::FailClosed; + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: if fail_closed { + HttpResponseInvocationOutcome::FailClosed + } else { + HttpResponseInvocationOutcome::FailOpen + }, + sequence: None, + input_size: 0, + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + }); + fail_closed.then(|| format!("middleware_failed: {reason}")) +} + +fn empty_preflight_outcome(headers: Vec) -> HttpResponsePreflightOutcome { + HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + headers, + declared_trailer_names: Vec::new(), + session: None, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + session_capacity_exhausted: false, + } +} + +fn failed_preflight_outcome( + headers: Vec, + reason: String, + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, +) -> HttpResponsePreflightOutcome { + HttpResponsePreflightOutcome { + allowed: false, + reason, + headers, + declared_trailer_names: Vec::new(), + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + } +} + +fn response_session_capacity_exhausted( + entries: Vec, + headers: Vec, +) -> HttpResponsePreflightOutcome { + let mut invocations = Vec::new(); + let fail_closed = entries.iter().any(|entry| { + collect_preflight_failure( + entry, + "middleware_session_capacity_exhausted", + &mut invocations, + ) + .is_some() + }); + HttpResponsePreflightOutcome { + allowed: !fail_closed, + reason: if fail_closed { + "middleware_failed: middleware_session_capacity_exhausted".into() + } else { + String::new() + }, + headers, + declared_trailer_names: Vec::new(), + session: None, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations, + session_capacity_exhausted: true, + } +} + +async fn end_stages(stages: &mut [HttpResponseStage], reason: HttpResponseSessionEndReason) { + for stage in stages { + stage.end(reason).await; + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use openshell_core::middleware::{HttpRequestView, InProcessMiddleware}; + use openshell_core::proto::{ + Decision, ExistingHeaderAction, HttpRequestResult, HttpResponseBodyResult, + HttpResponseBodyTransform, HttpResponsePreflightDecision, HttpResponsePreflightInspect, + HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, WriteHeader, + http_response_preflight_decision, + }; + use tokio_stream::wrappers::ReceiverStream; + use tokio_stream::wrappers::TcpListenerStream; + + use super::*; + + #[derive(Clone, Copy)] + enum Script { + HeadersOnly, + Stream, + WholeBody, + InvalidSequence, + } + + struct ResponseService { + script: Script, + } + + #[derive(Clone)] + struct RemoteResponseService; + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware + for RemoteResponseService + { + type EvaluateWebSocketSessionStream = super::super::WebSocketResponseStream; + + async fn describe( + &self, + _request: tonic::Request<()>, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(response_manifest( + "test/remote-response", + ))) + } + + async fn validate_config( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> + { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(HttpRequestResult { + decision: Decision::Allow as i32, + ..Default::default() + })) + } + + async fn evaluate_web_socket_session( + &self, + _request: tonic::Request< + tonic::Streaming, + >, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("HTTP response-only service")) + } + } + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::http_response_pre_return_server::HttpResponsePreReturn + for RemoteResponseService + { + type EvaluateStream = super::super::HttpResponseResultStream; + + async fn evaluate( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let mut requests = request.into_inner(); + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + while let Some(Ok(event)) = requests.next().await { + match event.event { + Some(http_response_event::Event::Preflight(_)) => { + let result = HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightDecision( + HttpResponsePreflightDecision { + decision: Some( + http_response_preflight_decision::Decision::Inspect( + HttpResponsePreflightInspect { + body_mode: + HttpResponseBodyMode::HeadersOnly as i32, + header_mutations: vec![write_header( + "cache-control", + "remote", + )], + ..Default::default() + }, + ), + ), + }, + ), + ), + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + Some(http_response_event::Event::SessionEnd(_)) | None => break, + _ => {} + } + } + }); + Ok(tonic::Response::new(Box::pin(ReceiverStream::new(receiver)))) + } + } + + #[tonic::async_trait] + impl InProcessMiddleware for ResponseService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/response".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpResponse + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> miette::Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _request: HttpRequestView<'_>, + ) -> miette::Result { + Ok(HttpRequestResult { + decision: Decision::Allow as i32, + ..Default::default() + }) + } + + async fn open_http_response_pre_return( + &self, + mut requests: mpsc::Receiver, + ) -> Result { + let (sender, receiver) = mpsc::channel(4); + let script = self.script; + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + let Some(event) = event.event else { + break; + }; + let result = match event { + http_response_event::Event::Preflight(_) => { + let (body_mode, header_mutations, declared_trailer_names) = match script + { + Script::HeadersOnly => ( + HttpResponseBodyMode::HeadersOnly, + vec![write_header("cache-control", "private")], + Vec::new(), + ), + Script::Stream | Script::InvalidSequence => ( + HttpResponseBodyMode::StreamBytes, + Vec::new(), + vec!["digest".into()], + ), + Script::WholeBody => { + (HttpResponseBodyMode::WholeBodyBytes, Vec::new(), Vec::new()) + } + }; + HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightDecision( + HttpResponsePreflightDecision { + decision: Some( + http_response_preflight_decision::Decision::Inspect( + HttpResponsePreflightInspect { + body_mode: body_mode as i32, + header_mutations, + declared_trailer_names, + ..Default::default() + }, + ), + ), + }, + ), + ), + } + } + http_response_event::Event::Body(body) => { + let Some(http_response_body_unit::Payload::Data(data)) = body.payload + else { + break; + }; + let replacement = match script { + Script::Stream | Script::InvalidSequence => { + data.to_ascii_uppercase() + } + Script::WholeBody => [b"whole:".as_slice(), &data].concat(), + Script::HeadersOnly => break, + }; + HttpResponseEventResult { + result: Some(http_response_event_result::Result::BodyResult( + HttpResponseBodyResult { + sequence: if matches!(script, Script::InvalidSequence) { + body.sequence + 1 + } else { + body.sequence + }, + decision: Some( + http_response_body_result::Decision::Transform( + HttpResponseBodyTransform { + replacement: Some( + http_response_body_transform::Replacement::Data( + replacement, + ), + ), + }, + ), + ), + ..Default::default() + }, + )), + } + } + http_response_event::Event::Trailers(_) => HttpResponseEventResult { + result: Some(http_response_event_result::Result::TrailersResult( + HttpResponseTrailersResult { + trailer_mutations: if matches!(script, Script::Stream) { + vec![write_header("digest", "sha-256=:test:")] + } else { + Vec::new() + }, + ..Default::default() + }, + )), + }, + http_response_event::Event::BodyEnd(_) => continue, + http_response_event::Event::SessionEnd(_) => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) + } + } + + fn write_header(name: &str, value: &str) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Write(WriteHeader { + name: name.into(), + value: value.into(), + on_existing: ExistingHeaderAction::Overwrite as i32, + })), + } + } + + fn response_manifest(name: &str) -> MiddlewareManifest { + MiddlewareManifest { + name: name.into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpResponse + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + fn entry(on_error: OnError) -> ChainEntry { + ChainEntry { + name: "response".into(), + implementation: "test/response".into(), + order: 0, + config: prost_types::Struct::default(), + on_error, + } + } + + fn input(status_code: u16) -> HttpResponsePreflightInput { + HttpResponsePreflightInput { + context: RequestContext { + request_id: "req-1".into(), + sandbox_id: "sandbox-1".into(), + ..Default::default() + }, + target: HttpRequestTarget { + scheme: "https".into(), + host: "example.com".into(), + port: 443, + method: "GET".into(), + path: "/data".into(), + query: String::new(), + }, + status_code, + declared_body_length: None, + headers: vec![HttpHeader { + name: "content-type".into(), + value: "text/plain".into(), + }], + connection_nominated_headers: Vec::new(), + } + } + + #[tokio::test] + async fn headers_only_preflight_applies_end_to_end_mutation() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::HeadersOnly, + })); + let outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("response preflight"); + + assert!(outcome.allowed); + assert_eq!( + outcome + .headers + .iter() + .find(|header| header.name == "cache-control") + .map(|header| header.value.as_str()), + Some("private") + ); + outcome + .session + .expect("headers-only session") + .finish(Vec::new()) + .await + .expect("finish headers-only session"); + } + + #[tokio::test] + async fn stream_mode_transforms_lockstep_units_and_declared_trailer() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Stream, + })); + let mut outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("response preflight"); + assert_eq!(outcome.declared_trailer_names, vec!["digest"]); + let mut session = outcome.session.take().expect("streaming session"); + + assert_eq!( + session + .push_body(b"hello".to_vec()) + .await + .expect("transform stream unit"), + vec![b"HELLO".to_vec()] + ); + let finish = session.finish(Vec::new()).await.expect("finish stream"); + assert!(finish.body_units.is_empty()); + assert_eq!( + finish.trailers, + vec![HttpHeader { + name: "digest".into(), + value: "sha-256=:test:".into(), + }] + ); + } + + #[tokio::test] + async fn whole_body_mode_releases_replacement_only_at_finish() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::WholeBody, + })); + let mut outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("response preflight"); + let mut session = outcome.session.take().expect("whole-body session"); + assert!(session.requires_whole_body()); + assert!( + session + .push_body(b"one".to_vec()) + .await + .expect("buffer first unit") + .is_empty() + ); + assert!( + session + .push_body(b"two".to_vec()) + .await + .expect("buffer second unit") + .is_empty() + ); + + let finish = session.finish(Vec::new()).await.expect("finish whole body"); + assert_eq!(finish.body_units, vec![b"whole:onetwo".to_vec()]); + } + + #[tokio::test] + async fn invalid_sequence_obeys_fail_open_and_fail_closed() { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::InvalidSequence, + })); + let mut outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("response preflight"); + let mut session = outcome.session.take().expect("stream session"); + let result = session.push_body(b"unchanged".to_vec()).await; + assert_eq!(result.is_ok(), allowed); + if let Ok(units) = result { + assert_eq!(units, vec![b"unchanged".to_vec()]); + } + } + } + + #[tokio::test] + async fn partial_response_rejects_body_mode_through_on_error() { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Stream, + })); + let outcome = runner + .preflight_http_response(&[entry(on_error)], input(206)) + .await + .expect("partial response preflight"); + assert_eq!(outcome.allowed, allowed); + assert!(outcome.session.is_none()); + if !allowed { + assert_eq!( + outcome.reason, + "middleware_failed: unsupported_partial_response" + ); + } + } + } + + #[tokio::test] + async fn remote_service_executes_through_http_response_pre_return_rpc() { + use openshell_core::proto::middleware::v1::http_response_pre_return_server::HttpResponsePreReturnServer; + use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddlewareServer; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind response middleware"); + let address = listener.local_addr().expect("response middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(RemoteResponseService)) + .add_service(HttpResponsePreReturnServer::new(RemoteResponseService)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = super::super::MiddlewareRegistry::connect_services( + Vec::new(), + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "remote-response".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: 4096, + allow_insecure_transport: true, + ..Default::default() + }], + ) + .await + .expect("connect remote response middleware"); + let runner = ChainRunner::from_registry(registry); + let outcome = runner + .preflight_http_response( + &[ChainEntry { + name: "response".into(), + implementation: "remote-response".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + input(200), + ) + .await + .expect("remote response preflight"); + + assert!(outcome.allowed); + assert_eq!( + outcome + .headers + .iter() + .find(|header| header.name == "cache-control") + .map(|header| header.value.as_str()), + Some("remote") + ); + outcome + .session + .expect("remote response session") + .finish(Vec::new()) + .await + .expect("finish remote response session"); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join response middleware server") + .expect("serve response middleware"); + } +} diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index bcd9c8ddb3..75686ecfbf 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -33,6 +33,14 @@ service SupervisorMiddleware { returns (stream WebSocketSessionEventResult); } +// HttpResponsePreReturn evaluates one ordered response stream for one selected +// middleware stage after OpenShell receives the final upstream response head +// and before it returns that response to the sandbox. +service HttpResponsePreReturn { + rpc Evaluate(stream HttpResponseEvent) + returns (stream HttpResponseEventResult); +} + // MiddlewareManifest describes one middleware service and the bindings it // exposes. The service is the operator-run gRPC server implementing // SupervisorMiddleware. @@ -57,13 +65,12 @@ message MiddlewareManifest { message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. PR 1 supports PRE_CREDENTIALS. PRE_RETURN is - // reserved for the return-path follow-up and is rejected by current - // manifest validation. + // Supported evaluation phase. SupervisorMiddlewarePhase phase = 2; // Maximum logical payload or replacement this binding can process. For - // HTTP_REQUEST this is the request body; for WEBSOCKET_MESSAGE this is one - // complete message. Required for every payload-bearing operation. + // HTTP_REQUEST this is the request body; for HTTP_RESPONSE this is a whole + // body or one streaming input/replacement unit; for WEBSOCKET_MESSAGE this + // is one complete message. Required for every payload-bearing operation. uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. @@ -113,7 +120,7 @@ message HttpRequestEvaluation { string middleware_name = 7; } -// HttpHeader is one request header line. +// HttpHeader is one HTTP header line. message HttpHeader { // Lowercased header name. string name = 1; @@ -121,11 +128,145 @@ message HttpHeader { string value = 2; } +// HttpResponseEvent is one ordered event in a stage-local response stream. +message HttpResponseEvent { + oneof event { + HttpResponsePreflight preflight = 1; + HttpResponseBodyUnit body = 2; + HttpResponseBodyEnd body_end = 3; + HttpResponseTrailers trailers = 4; + HttpResponseSessionEnd session_end = 5; + } +} + +// HttpResponseEventResult acknowledges response preflight, body, or trailers. +// Body end and session end do not produce results in V1. +message HttpResponseEventResult { + oneof result { + HttpResponsePreflightDecision preflight_decision = 1; + HttpResponseBodyResult body_result = 2; + HttpResponseTrailersResult trailers_result = 3; + } +} + +// HttpResponsePreflight exposes the current final response head to one stage. +message HttpResponsePreflight { + RequestContext context = 1; + HttpRequestTarget target = 2; + uint32 status_code = 3; + repeated HttpHeader headers = 4; + string middleware_name = 5; + google.protobuf.Struct config = 6; + // Effective minimum of the platform, registration, and binding limits. + uint64 max_payload_bytes = 7; +} + +// HttpResponsePreflightDecision either declines the response or selects an +// inspection mode and mutations. +message HttpResponsePreflightDecision { + oneof decision { + HttpResponsePreflightSkip skip = 1; + HttpResponsePreflightInspect inspect = 2; + } +} + +message HttpResponsePreflightSkip { + // Free-form diagnostic omitted from sandbox responses and OCSF fields. + string reason = 1; + string reason_code = 2; + repeated Finding findings = 3; + map metadata = 4; +} + +message HttpResponsePreflightInspect { + HttpResponseBodyMode body_mode = 1; + repeated HeaderMutation header_mutations = 2; + // Trailer names this stage may add after observing the final body bytes. + repeated string declared_trailer_names = 3; + // Free-form diagnostic omitted from sandbox responses and OCSF fields. + string reason = 4; + repeated Finding findings = 5; + map metadata = 6; +} + +enum HttpResponseBodyMode { + HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; + HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; + HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; + HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; +} + +// HttpResponseBodyUnit contains one supervisor-defined logical body unit. Unit +// boundaries have no HTTP transport or application semantic meaning. +message HttpResponseBodyUnit { + // Contiguous and stage-local, starting at 1. + uint64 sequence = 1; + oneof payload { + bytes data = 2; + } +} + +message HttpResponseBodyResult { + // Acknowledges exactly one outstanding body unit. + uint64 sequence = 1; + oneof decision { + HttpResponseBodyPassThrough pass_through = 2; + HttpResponseBodyTransform transform = 3; + } + // Free-form diagnostic omitted from sandbox responses and OCSF fields. + string reason = 4; + repeated Finding findings = 5; + map metadata = 6; +} + +message HttpResponseBodyPassThrough {} + +message HttpResponseBodyTransform { + // Presence is required. Present empty data deletes the input unit. + oneof replacement { + bytes data = 1; + } +} + +message HttpResponseBodyEnd { + // Zero when streaming produced no units; otherwise the final input sequence. + uint64 final_sequence = 1; +} + +message HttpResponseTrailers { + repeated HttpHeader headers = 1; +} + +message HttpResponseTrailersResult { + repeated HeaderMutation trailer_mutations = 1; + // Free-form diagnostic omitted from sandbox responses and OCSF fields. + string reason = 2; + repeated Finding findings = 3; + map metadata = 4; +} + +enum HttpResponseSessionEndReason { + HTTP_RESPONSE_SESSION_END_REASON_UNSPECIFIED = 0; + HTTP_RESPONSE_SESSION_END_REASON_NORMAL = 1; + HTTP_RESPONSE_SESSION_END_REASON_STAGE_SKIPPED = 2; + HTTP_RESPONSE_SESSION_END_REASON_CLIENT_DISCONNECT = 3; + HTTP_RESPONSE_SESSION_END_REASON_UPSTREAM_ERROR = 4; + HTTP_RESPONSE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + HTTP_RESPONSE_SESSION_END_REASON_POLICY_RELOAD = 6; + HTTP_RESPONSE_SESSION_END_REASON_CANCELLATION = 7; + HTTP_RESPONSE_SESSION_END_REASON_PROTOCOL_ERROR = 8; +} + +message HttpResponseSessionEnd { + HttpResponseSessionEndReason reason = 1; +} + // Supervisor operation selected for middleware evaluation. enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE = 3; } // Ordered phase within a supervisor operation. From 28d57569727444a9f578649e42bbcb3071a374c4 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 31 Aug 2026 19:44:39 -0700 Subject: [PATCH 3/6] feat(network): enforce response middleware on HTTP relay Signed-off-by: Piotr Mlocek --- .../src/l7/middleware.rs | 77 +- .../src/l7/relay.rs | 160 +- .../src/l7/rest.rs | 1476 ++++++++++++++++- .../openshell-supervisor-network/src/proxy.rs | 12 +- 4 files changed, 1699 insertions(+), 26 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 6305653f6a..c50797693c 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -423,7 +423,8 @@ pub(super) fn middleware_chain_body_limit( .max() } -pub async fn apply_middleware_chain( +#[allow(clippy::too_many_arguments)] +pub async fn apply_middleware_chain_with_request_id( req: crate::l7::provider::L7Request, client: &mut C, ctx: &L7EvalContext, @@ -431,8 +432,9 @@ pub async fn apply_middleware_chain( runner: &openshell_supervisor_middleware::ChainRunner, generation_guard: &PolicyGenerationGuard, transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, + request_id: &str, ) -> Result { - apply_middleware_chain_for_scheme( + apply_middleware_chain_for_scheme_with_request_id( req, client, ctx, @@ -441,6 +443,7 @@ pub async fn apply_middleware_chain( runner, generation_guard, transformed_body_policy, + request_id, ) .await } @@ -455,6 +458,35 @@ pub async fn apply_middleware_chain_for_scheme, +) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + apply_middleware_chain_for_scheme_with_request_id( + req, + client, + ctx, + scheme, + chain, + runner, + generation_guard, + transformed_body_policy, + &request_id, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_middleware_chain_for_scheme_with_request_id< + C: AsyncRead + AsyncWrite + Unpin + Send, +>( + req: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + scheme: &str, + chain: Vec, + runner: &openshell_supervisor_middleware::ChainRunner, + generation_guard: &PolicyGenerationGuard, + transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, + request_id: &str, ) -> Result { if chain.is_empty() { return Ok(MiddlewareApplyResult::Allowed(req)); @@ -479,7 +511,7 @@ pub async fn apply_middleware_chain_for_scheme, query: String, body: Vec, + request_id: &str, ) -> openshell_supervisor_middleware::HttpRequestInput { openshell_supervisor_middleware::HttpRequestInput { - request_id: uuid::Uuid::new_v4().to_string(), + request_id: request_id.to_string(), sandbox_id: sandbox.sandbox_id.clone(), sandbox_name: sandbox.sandbox_name.clone(), workspace: ctx.workspace.clone(), @@ -637,6 +672,32 @@ pub(super) fn middleware_request_input( } } +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(super) fn middleware_request_input( + sandbox: &openshell_ocsf::SandboxContext, + scheme: &str, + req: &crate::l7::provider::L7Request, + ctx: &L7EvalContext, + headers: Vec<(String, String)>, + connection_nominated_headers: Vec, + query: String, + body: Vec, +) -> openshell_supervisor_middleware::HttpRequestInput { + let request_id = uuid::Uuid::new_v4().to_string(); + middleware_request_input_with_id( + sandbox, + scheme, + req, + ctx, + headers, + connection_nominated_headers, + query, + body, + &request_id, + ) +} + pub(super) fn raw_query_from_request_headers(headers: &[u8]) -> Result { let header_str = std::str::from_utf8(headers).map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; @@ -1116,7 +1177,7 @@ mod tests { body_length: crate::l7::provider::BodyLength::None, }; - let input = super::middleware_request_input( + let input = super::middleware_request_input_with_id( &sandbox, "https", &req, @@ -1125,11 +1186,13 @@ mod tests { Vec::new(), String::new(), Vec::new(), + "exchange-123", ); assert_eq!(input.sandbox_name, "nightly-build"); assert_eq!(input.sandbox_id, "sbx-123"); assert_eq!(input.workspace, "wrks-default"); + assert_eq!(input.request_id, "exchange-123"); } #[tokio::test] diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 2697fedb3c..bc290acb11 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -8,13 +8,14 @@ //! and either forwards or denies the request. use crate::l7::middleware::{ - MiddlewareApplyResult, UninspectableTrafficGate, apply_middleware_chain, - emit_middleware_uninspectable, middleware_network_input, uninspectable_traffic_gate, + MiddlewareApplyResult, UninspectableTrafficGate, apply_middleware_chain_with_request_id, + emit_middleware_uninspectable, middleware_network_input, raw_query_from_request_headers, + uninspectable_traffic_gate, }; #[cfg(test)] use crate::l7::middleware::{ middleware_chain_body_limit, middleware_events, middleware_request_input, - raw_query_from_request_headers, resolve_unbuffered_body, + resolve_unbuffered_body, }; use crate::l7::provider::{L7Provider, RelayOutcome}; use crate::l7::rest::WebSocketExtensionMode; @@ -288,13 +289,20 @@ async fn relay_http_request_with_credential_rejection( upstream: &mut U, options: crate::l7::rest::RelayRequestOptions<'_>, ctx: &L7EvalContext, + response_middleware: Option>, ) -> Result> where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, { - match crate::l7::rest::relay_http_request_with_options_guarded( - request, client, upstream, options, + match Box::pin( + crate::l7::rest::relay_http_request_with_response_middleware_guarded( + request, + client, + upstream, + options, + response_middleware, + ), ) .await { @@ -310,6 +318,39 @@ where } } +fn http_response_middleware_relay<'a>( + request: &crate::l7::provider::L7Request, + ctx: &'a L7EvalContext, + scheme: &str, + request_id: &str, + chain: &'a [openshell_supervisor_middleware::ChainEntry], + runner: &'a openshell_supervisor_middleware::ChainRunner, + generation_guard: Option<&'a PolicyGenerationGuard>, +) -> crate::l7::rest::HttpResponseMiddlewareRelay<'a> { + let sandbox = openshell_ocsf::ctx::ctx(); + crate::l7::rest::HttpResponseMiddlewareRelay { + chain, + runner, + request_context: openshell_core::proto::RequestContext { + request_id: request_id.to_string(), + sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox.sandbox_name.clone(), + workspace: ctx.workspace.clone(), + originating_process: None, + }, + target: openshell_core::proto::HttpRequestTarget { + scheme: scheme.to_string(), + host: ctx.host.clone(), + port: u32::from(ctx.port), + method: request.action.clone(), + path: request.target.clone(), + query: raw_query_from_request_headers(&request.raw_header).unwrap_or_default(), + }, + policy_name: &ctx.policy_name, + generation_guard, + } +} + #[derive(Default)] pub(crate) struct UpgradeRelayOptions<'a> { pub(crate) websocket_request: bool, @@ -736,13 +777,15 @@ where if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let response_chain = chain.clone(); + let request_id = uuid::Uuid::new_v4().to_string(); let websocket_chain = websocket_request.then(|| chain.clone()); // Route selection resolved `config` per request, so re-check the // body against that protocol's policy after every transforming // stage (a no-op for REST and websocket, whose policy inputs the // chain cannot mutate). let validate = transformed_body_validator(config, &engine, ctx, &request_info); - let middleware_result = apply_middleware_chain( + let middleware_result = apply_middleware_chain_with_request_id( req, client, ctx, @@ -750,6 +793,7 @@ where engine.middleware_runner(), engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + &request_id, ) .await; let req = match middleware_result? { @@ -848,6 +892,15 @@ where port: ctx.port, }, ctx, + Some(http_response_middleware_relay( + &req, + ctx, + "https", + &request_id, + &response_chain, + engine.middleware_runner(), + Some(engine.generation_guard()), + )), ) .await; let outcome_result = match outcome_result { @@ -1461,11 +1514,13 @@ where if allowed || config.enforcement == EnforcementMode::Audit { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let response_chain = chain.clone(); + let request_id = uuid::Uuid::new_v4().to_string(); let websocket_chain = websocket_request.then(|| chain.clone()); // REST and websocket-upgrade policy evaluates only the method, // path, and query, which a middleware result cannot mutate, so no // per-stage body re-check is needed. - let middleware_result = apply_middleware_chain( + let middleware_result = apply_middleware_chain_with_request_id( req, client, ctx, @@ -1473,6 +1528,7 @@ where engine.middleware_runner(), engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + &request_id, ) .await; let req = match middleware_result? { @@ -1587,6 +1643,15 @@ where port: ctx.port, }, ctx, + Some(http_response_middleware_relay( + &req_with_auth, + ctx, + "https", + &request_id, + &response_chain, + engine.middleware_runner(), + Some(engine.generation_guard()), + )), ) .await; let outcome_result = match outcome_result { @@ -1875,12 +1940,14 @@ where if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let response_chain = chain.clone(); + let request_id = uuid::Uuid::new_v4().to_string(); // Policy admitted the original body above; re-check the body // against the same body-aware policy after every transforming // stage so a middleware cannot smuggle a denied operation to the // upstream or the next stage. let validate = transformed_body_validator(config, engine, ctx, &request_info); - let req = match apply_middleware_chain( + let req = match apply_middleware_chain_with_request_id( req, client, ctx, @@ -1888,6 +1955,7 @@ where engine.middleware_runner(), engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + &request_id, ) .await? { @@ -1946,6 +2014,15 @@ where ..Default::default() }, ctx, + Some(http_response_middleware_relay( + &req, + ctx, + "https", + &request_id, + &response_chain, + engine.middleware_runner(), + Some(engine.generation_guard()), + )), ) .await? else { @@ -2115,12 +2192,14 @@ where if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let response_chain = chain.clone(); + let request_id = uuid::Uuid::new_v4().to_string(); // Policy admitted the original body above; re-check the body // against the same body-aware policy after every transforming // stage so a middleware cannot smuggle a denied operation to the // upstream or the next stage. let validate = transformed_body_validator(config, engine, ctx, &request_info); - let req = match apply_middleware_chain( + let req = match apply_middleware_chain_with_request_id( req, client, ctx, @@ -2128,6 +2207,7 @@ where engine.middleware_runner(), engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + &request_id, ) .await? { @@ -2181,6 +2261,15 @@ where ..Default::default() }, ctx, + Some(http_response_middleware_relay( + &req, + ctx, + "https", + &request_id, + &response_chain, + engine.middleware_runner(), + Some(engine.generation_guard()), + )), ) .await? else { @@ -2733,6 +2822,8 @@ where ocsf_emit!(event); } + let request_id = uuid::Uuid::new_v4().to_string(); + let mut response_selection = None; let req = if let Some(engine) = middleware_engine { let input = middleware_network_input(ctx); let (chain, generation) = engine.query_middleware_chain_with_generation(&input)?; @@ -2740,9 +2831,10 @@ where return Ok(()); } let runner = engine.middleware_runner()?; + response_selection = Some((chain.clone(), runner.clone())); // The passthrough path enforces no L7 policy, so there is no // body-aware decision to re-check after a transformation. - match apply_middleware_chain( + match apply_middleware_chain_with_request_id( req, client, ctx, @@ -2750,6 +2842,7 @@ where &runner, generation_guard, openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + &request_id, ) .await? { @@ -2811,6 +2904,17 @@ where let scoped_ctx = scoped_context_for_request(ctx, &req_with_auth); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); let resolver = ctx.secret_resolver.as_deref(); + let response_middleware = response_selection.as_ref().map(|(chain, runner)| { + http_response_middleware_relay( + &req_with_auth, + ctx, + "http", + &request_id, + chain, + runner, + Some(generation_guard), + ) + }); // Forward request with credential rewriting and relay the response. // relay_http_request_with_resolver handles both directions: it sends @@ -2826,6 +2930,7 @@ where ..Default::default() }, ctx, + response_middleware, ) .await? else { @@ -3132,6 +3237,7 @@ mod tests { ..options }, &ctx, + None, ) .await .expect("typed credential denial"); @@ -6191,6 +6297,40 @@ network_policies: assert_eq!(input.scheme, "http"); } + #[test] + fn response_middleware_context_reuses_exchange_request_id() { + let req = crate::l7::provider::L7Request { + action: "GET".into(), + target: "/v1/data".into(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /v1/data?cursor=next HTTP/1.1\r\nHost: api.example.test\r\n\r\n" + .to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + workspace: "workspace-1".into(), + policy_name: "api".into(), + ..Default::default() + }; + let runner = openshell_supervisor_middleware::ChainRunner::default(); + let chain = Vec::new(); + let response = http_response_middleware_relay( + &req, + &ctx, + "https", + "exchange-123", + &chain, + &runner, + None, + ); + + assert_eq!(response.request_context.request_id, "exchange-123"); + assert_eq!(response.target.query, "cursor=next"); + assert_eq!(response.target.scheme, "https"); + } + #[test] fn middleware_ocsf_events_are_audit_safe() { use openshell_supervisor_middleware::{ diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 93315a671a..49c222bdba 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -12,7 +12,10 @@ use crate::opa::PolicyGenerationGuard; use aws_sigv4::http_request::SignableBody; use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; -use openshell_core::proto::{ExistingHeaderAction, HeaderMutation, header_mutation}; +use openshell_core::proto::{ + ExistingHeaderAction, HeaderMutation, HttpHeader, HttpRequestTarget, RequestContext, + header_mutation, +}; use openshell_core::secrets::{ CREDENTIAL_MARKER_SCAN_TAIL_BYTES, SecretResolver, contains_reserved_credential_marker, contains_reserved_credential_marker_bytes, rewrite_http_header_block, @@ -20,7 +23,7 @@ use openshell_core::secrets::{ use openshell_ocsf::ctx::ctx as ocsf_ctx; use sha1::{Digest, Sha1}; use std::collections::{HashMap, HashSet}; -use std::fmt; +use std::fmt::{self, Write as _}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::debug; @@ -49,6 +52,7 @@ async fn max_middleware_body_bytes() -> usize { chain[0].max_payload_bytes() } const RELAY_BUF_SIZE: usize = 8192; +const RESPONSE_UNIT_COALESCE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2); const HTTP_METHOD_PREFIXES: &[&[u8]] = &[ b"GET ", b"HEAD ", @@ -798,6 +802,30 @@ pub(crate) async fn relay_http_request_with_options_guarded( upstream: &mut U, options: RelayRequestOptions<'_>, ) -> Result +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ + relay_http_request_with_response_middleware_guarded(req, client, upstream, options, None).await +} + +/// Context retained from request evaluation for the matching response hook. +pub(crate) struct HttpResponseMiddlewareRelay<'a> { + pub(crate) chain: &'a [openshell_supervisor_middleware::ChainEntry], + pub(crate) runner: &'a openshell_supervisor_middleware::ChainRunner, + pub(crate) request_context: RequestContext, + pub(crate) target: HttpRequestTarget, + pub(crate) policy_name: &'a str, + pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, +} + +pub(crate) async fn relay_http_request_with_response_middleware_guarded( + req: &L7Request, + client: &mut C, + upstream: &mut U, + options: RelayRequestOptions<'_>, + response_middleware: Option>, +) -> Result where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, @@ -1152,6 +1180,7 @@ where websocket: websocket_response, client_requested_upgrade, }, + response_middleware, ) .await?; @@ -3124,6 +3153,7 @@ async fn relay_response( upstream: &mut U, client: &mut C, options: RelayResponseOptions, + response_middleware: Option>, ) -> Result where U: AsyncRead + Unpin, @@ -3133,7 +3163,8 @@ where let mut buf = Vec::with_capacity(4096); let mut tmp = [0u8; 1024]; - // Read response headers + // Read response headers. Forward interim responses unchanged, but retain + // the final response head until response middleware preflight completes. loop { if buf.len() > MAX_HEADER_BYTES { return Err(miette!("HTTP response headers exceed limit")); @@ -3149,6 +3180,21 @@ where } buf.extend_from_slice(&tmp[..n]); + while let Some(position) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + let header_end = position + 4; + let header_str = String::from_utf8_lossy(&buf[..header_end]); + let status_code = parse_status_code(&header_str).unwrap_or(200); + if (100..200).contains(&status_code) && status_code != 101 { + client + .write_all(&buf[..header_end]) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + buf.drain(..header_end); + continue; + } + break; + } if buf.windows(4).any(|w| w == b"\r\n\r\n") { break; } @@ -3204,6 +3250,24 @@ where }); } + if let Some(response_middleware) = response_middleware + && let Some(outcome) = Box::pin(relay_response_through_middleware( + request_method, + upstream, + client, + response_middleware, + &buf, + header_end, + status_code, + body_length, + server_wants_close, + event_stream, + )) + .await? + { + return Ok(outcome); + } + // Bodiless responses (HEAD, 1xx, 204, 304): forward headers only, skip body if is_bodiless_response(request_method, status_code) { client @@ -3292,6 +3356,943 @@ where Ok(RelayOutcome::Reusable) } +#[allow(clippy::too_many_arguments)] +async fn relay_response_through_middleware( + request_method: &str, + upstream: &mut U, + client: &mut C, + middleware: HttpResponseMiddlewareRelay<'_>, + buffered: &[u8], + header_end: usize, + status_code: u16, + body_length: BodyLength, + server_wants_close: bool, + event_stream: bool, +) -> Result> +where + U: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + if let Some(guard) = middleware.generation_guard { + guard.ensure_current()?; + } + let header_bytes = &buffered[..header_end]; + let parsed = match parse_response_head_for_middleware(header_bytes) { + Ok(parsed) => parsed, + Err(error) => { + debug!(error = %error, "HTTP response head normalization failed"); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + let original_headers = parsed.headers.clone(); + let upstream_declared_trailers = parsed.declared_trailers.clone(); + let input = openshell_supervisor_middleware::HttpResponsePreflightInput { + context: middleware.request_context, + target: middleware.target.clone(), + status_code, + declared_body_length: match body_length { + BodyLength::ContentLength(length) => Some(length), + BodyLength::Chunked | BodyLength::None => None, + }, + headers: parsed.headers, + connection_nominated_headers: parsed.connection_nominated, + }; + let preflight = match middleware + .runner + .preflight_http_response(middleware.chain, input) + .await + { + Ok(preflight) => preflight, + Err(error) => { + debug!(error = %error, "HTTP response middleware preflight failed"); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + if !preflight.allowed { + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &preflight.invocations, + ); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &preflight.invocations, + ); + + let Some(mut session) = preflight.session else { + // No response binding selected (or every selected stage skipped), so + // retain the existing byte-for-byte relay behavior. + debug_assert_eq!(preflight.headers, original_headers); + return Ok(None); + }; + + let status_line = response_status_line(header_bytes)?; + let bodiless = is_bodiless_response(request_method, status_code); + if bodiless { + let finish = match session.finish(Vec::new()).await { + Ok(finish) => finish, + Err(error) => { + debug!(error = %error, "HTTP response middleware finalization failed"); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + let mut headers = preflight.headers; + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &finish.invocations, + ); + if finish.strip_stale_integrity_headers { + strip_response_integrity_headers(&mut headers); + } + let head = serialize_response_head( + &status_line, + &headers, + ResponseFraming::Preserve(body_length), + server_wants_close, + &[], + ); + client.write_all(&head).await.into_diagnostic()?; + client.flush().await.into_diagnostic()?; + return Ok(Some(if server_wants_close { + RelayOutcome::Consumed + } else { + RelayOutcome::Reusable + })); + } + + let whole_body = session.requires_whole_body(); + let unit_limit = session.stream_unit_limit().max(1); + let committed = if whole_body { + false + } else { + let mut declared_trailers = upstream_declared_trailers; + for name in &preflight.declared_trailer_names { + if !declared_trailers.contains(name) { + declared_trailers.push(name.clone()); + } + } + let head = serialize_response_head( + &status_line, + &preflight.headers, + ResponseFraming::Chunked, + server_wants_close, + &declared_trailers, + ); + client.write_all(&head).await.into_diagnostic()?; + client.flush().await.into_diagnostic()?; + true + }; + + let mut reader = BufferedResponseReader::new(upstream, &buffered[header_end..]); + let body_result = relay_normalized_response_body( + &mut reader, + &mut session, + client, + body_length, + server_wants_close, + event_stream, + committed, + unit_limit, + middleware.generation_guard, + ) + .await; + let trailers = match body_result { + Ok(trailers) => trailers, + Err(error) => { + let end_reason = if middleware + .generation_guard + .is_some_and(PolicyGenerationGuard::is_stale) + { + openshell_core::proto::HttpResponseSessionEndReason::PolicyReload + } else if error + .to_string() + .starts_with("HTTP response middleware failure:") + { + openshell_core::proto::HttpResponseSessionEndReason::MiddlewareFailure + } else { + openshell_core::proto::HttpResponseSessionEndReason::UpstreamError + }; + session.end(end_reason).await; + if committed { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + true, + ); + return Err(error); + } + debug!(error = %error, "HTTP response processing failed before commitment"); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + + if let Some(guard) = middleware.generation_guard + && let Err(error) = guard.ensure_current() + { + session + .end(openshell_core::proto::HttpResponseSessionEndReason::PolicyReload) + .await; + return Err(error); + } + + let finish = match session.finish(trailers).await { + Ok(finish) => finish, + Err(error) => { + if committed { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + true, + ); + return Err(miette!( + "HTTP response middleware failed after commitment: {error}" + )); + } + debug!(error = %error, "HTTP response middleware failed before commitment"); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &finish.invocations, + ); + + if whole_body { + let mut headers = preflight.headers; + if finish.strip_stale_integrity_headers { + strip_response_integrity_headers(&mut headers); + } + let output_length = finish + .body_units + .iter() + .try_fold(0usize, |total, unit| total.checked_add(unit.len())) + .ok_or_else(|| miette!("HTTP response middleware output length overflow"))?; + let framing = if finish.trailers.is_empty() { + ResponseFraming::ContentLength(output_length as u64) + } else { + ResponseFraming::Chunked + }; + let trailer_names: Vec = finish + .trailers + .iter() + .map(|header| header.name.clone()) + .collect(); + let head = serialize_response_head( + &status_line, + &headers, + framing, + server_wants_close, + &trailer_names, + ); + client.write_all(&head).await.into_diagnostic()?; + if matches!(framing, ResponseFraming::Chunked) { + for unit in &finish.body_units { + write_chunk(client, unit).await?; + } + write_response_trailers(client, &finish.trailers).await?; + } else { + for unit in &finish.body_units { + client.write_all(unit).await.into_diagnostic()?; + } + } + } else { + for unit in &finish.body_units { + write_chunk(client, unit).await?; + } + write_response_trailers(client, &finish.trailers).await?; + } + client.flush().await.into_diagnostic()?; + Ok(Some( + if server_wants_close || matches!(body_length, BodyLength::None) { + RelayOutcome::Consumed + } else { + RelayOutcome::Reusable + }, + )) +} + +fn emit_http_response_middleware_invocations( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + invocations: &[openshell_supervisor_middleware::HttpResponseInvocation], +) { + for event in + http_response_middleware_invocation_events(policy_name, target, status_code, invocations) + { + openshell_ocsf::ocsf_emit!(event); + } +} + +fn http_response_middleware_invocation_events( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + invocations: &[openshell_supervisor_middleware::HttpResponseInvocation], +) -> Vec { + invocations + .iter() + .map(|invocation| { + let outcome = format!("{:?}", invocation.outcome).to_ascii_lowercase(); + let failed = invocation.failed; + openshell_ocsf::HttpActivityBuilder::new(ocsf_ctx()) + .activity(openshell_ocsf::ActivityId::Other) + .action(if failed { + openshell_ocsf::ActionId::Other + } else { + openshell_ocsf::ActionId::Allowed + }) + .disposition(if failed { + openshell_ocsf::DispositionId::Error + } else { + openshell_ocsf::DispositionId::Allowed + }) + .severity(if failed { + openshell_ocsf::SeverityId::Medium + } else { + openshell_ocsf::SeverityId::Informational + }) + .status(if failed { + openshell_ocsf::StatusId::Failure + } else { + openshell_ocsf::StatusId::Success + }) + .http_request(openshell_ocsf::HttpRequest::new( + &target.method, + openshell_ocsf::Url::new( + &target.scheme, + &target.host, + &target.path, + u16::try_from(target.port).unwrap_or_default(), + ), + )) + .http_response(openshell_ocsf::HttpResponse { code: status_code }) + .dst_endpoint(openshell_ocsf::Endpoint::from_domain( + &target.host, + u16::try_from(target.port).unwrap_or_default(), + )) + .firewall_rule(policy_name, "supervisor-middleware") + .unmapped("middleware_config", invocation.config_name.as_str()) + .unmapped( + "middleware_implementation", + invocation.implementation.as_str(), + ) + .unmapped("response_middleware_outcome", outcome.as_str()) + .unmapped("sequence", invocation.sequence.unwrap_or_default()) + .unmapped("input_bytes", invocation.input_size) + .unmapped("failed", failed) + .message(format!( + "HTTP_RESPONSE_MIDDLEWARE config={} implementation={} outcome={} sequence={} input_bytes={} failed={failed}", + invocation.config_name, + invocation.implementation, + outcome, + invocation.sequence.unwrap_or_default(), + invocation.input_size, + )) + .build() + }) + .collect() +} + +fn emit_http_response_middleware_failure( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + committed: bool, +) { + let status_code = status_code.to_string(); + let event = openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) + .severity(openshell_ocsf::SeverityId::High) + .finding_info(openshell_ocsf::FindingInfo::new( + "openshell.middleware.http_response_failure", + "HTTP response middleware delivery failure", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("host", target.host.as_str()), + ( + "commitment", + if committed { + "after_commit" + } else { + "before_commit" + }, + ), + ("upstream_status", status_code.as_str()), + ]) + .message(if committed { + "HTTP response middleware failed after response commitment" + } else { + "HTTP response middleware failed before response commitment" + }) + .build(); + openshell_ocsf::ocsf_emit!(event); +} + +#[derive(Debug)] +struct ParsedResponseHead { + headers: Vec, + connection_nominated: Vec, + declared_trailers: Vec, +} + +fn parse_response_head_for_middleware(header_bytes: &[u8]) -> Result { + let header = std::str::from_utf8(header_bytes) + .map_err(|_| miette!("HTTP response headers contain invalid UTF-8"))?; + if parse_status_code(header).is_none() { + return Err(miette!("HTTP response status line is malformed")); + } + let mut nominated = HashSet::new(); + let mut declared_trailers = Vec::new(); + for line in header.split("\r\n").skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + if name.eq_ignore_ascii_case("connection") { + for token in value + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()) + { + nominated.insert(token.to_ascii_lowercase()); + } + } else if name.eq_ignore_ascii_case("trailer") { + for token in parse_http_token_list(value)? { + let token = token.to_ascii_lowercase(); + if !declared_trailers.contains(&token) { + declared_trailers.push(token); + } + } + } + } + let mut headers = Vec::new(); + for line in header.split("\r\n").skip(1).filter(|line| !line.is_empty()) { + let (name, value) = line + .split_once(':') + .ok_or_else(|| miette!("Malformed HTTP response header field"))?; + let name = name.to_ascii_lowercase(); + if nominated.contains(&name) + || matches!( + name.as_str(), + "connection" + | "content-length" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) + { + continue; + } + headers.push(HttpHeader { + name, + value: value.trim().to_string(), + }); + } + let mut connection_nominated: Vec<_> = nominated.into_iter().collect(); + connection_nominated.sort(); + Ok(ParsedResponseHead { + headers, + connection_nominated, + declared_trailers, + }) +} + +fn response_status_line(header_bytes: &[u8]) -> Result { + let line_end = header_bytes + .windows(2) + .position(|window| window == b"\r\n") + .ok_or_else(|| miette!("HTTP response status line is incomplete"))?; + std::str::from_utf8(&header_bytes[..line_end]) + .map(str::to_string) + .map_err(|_| miette!("HTTP response status line contains invalid UTF-8")) +} + +#[derive(Clone, Copy)] +enum ResponseFraming { + Preserve(BodyLength), + ContentLength(u64), + Chunked, +} + +fn serialize_response_head( + status_line: &str, + headers: &[HttpHeader], + framing: ResponseFraming, + connection_close: bool, + trailer_names: &[String], +) -> Vec { + let mut output = format!("{status_line}\r\n"); + for header in headers { + output.push_str(&header.name); + output.push_str(": "); + output.push_str(&header.value); + output.push_str("\r\n"); + } + match framing { + ResponseFraming::Preserve(BodyLength::ContentLength(length)) + | ResponseFraming::ContentLength(length) => { + write!(&mut output, "Content-Length: {length}\r\n") + .expect("writing to a String cannot fail"); + } + ResponseFraming::Preserve(BodyLength::Chunked) | ResponseFraming::Chunked => { + output.push_str("Transfer-Encoding: chunked\r\n"); + } + ResponseFraming::Preserve(BodyLength::None) => {} + } + if !trailer_names.is_empty() { + output.push_str("Trailer: "); + output.push_str(&trailer_names.join(", ")); + output.push_str("\r\n"); + } + if connection_close { + output.push_str("Connection: close\r\n"); + } + output.push_str("\r\n"); + output.into_bytes() +} + +fn strip_response_integrity_headers(headers: &mut Vec) { + headers.retain(|header| { + !matches!( + header.name.to_ascii_lowercase().as_str(), + "accept-ranges" + | "etag" + | "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "signature" + | "signature-input" + ) + }); +} + +struct BufferedResponseReader<'a, R> { + upstream: &'a mut R, + buffered: &'a [u8], + position: usize, +} + +impl<'a, R: AsyncRead + Unpin> BufferedResponseReader<'a, R> { + fn new(upstream: &'a mut R, buffered: &'a [u8]) -> Self { + Self { + upstream, + buffered, + position: 0, + } + } + + async fn read_some(&mut self, limit: usize) -> Result>> { + if self.position < self.buffered.len() { + let end = self.position.saturating_add(limit).min(self.buffered.len()); + let data = self.buffered[self.position..end].to_vec(); + self.position = end; + return Ok(Some(data)); + } + let mut data = vec![0u8; limit.max(1)]; + let count = self.upstream.read(&mut data).await.into_diagnostic()?; + if count == 0 { + return Ok(None); + } + data.truncate(count); + Ok(Some(data)) + } + + async fn read_exact_vec(&mut self, length: usize) -> Result> { + let mut output = Vec::with_capacity(length); + while output.len() < length { + let remaining = length - output.len(); + let Some(data) = self.read_some(remaining).await? else { + return Err(miette!("HTTP response body ended unexpectedly")); + }; + output.extend_from_slice(&data); + } + Ok(output) + } + + async fn read_line(&mut self) -> Result> { + let mut line = Vec::new(); + loop { + let Some(byte) = self.read_some(1).await? else { + return Err(miette!("HTTP response ended before line terminator")); + }; + line.push(byte[0]); + if line.len() > MAX_HEADER_BYTES { + return Err(miette!("HTTP response line exceeds limit")); + } + if line.ends_with(b"\r\n") { + line.truncate(line.len() - 2); + return Ok(line); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn relay_normalized_response_body( + reader: &mut BufferedResponseReader<'_, R>, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + body_length: BodyLength, + server_wants_close: bool, + event_stream: bool, + committed: bool, + unit_limit: usize, + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + let mut pending = Vec::with_capacity(unit_limit); + match body_length { + BodyLength::ContentLength(mut remaining) => { + while remaining > 0 { + let length = usize::try_from(remaining) + .unwrap_or(unit_limit) + .min(unit_limit); + let unit = reader.read_exact_vec(length).await?; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + remaining -= unit.len() as u64; + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + committed, + unit_limit, + ) + .await?; + } + flush_normalized_response_bytes(session, client, pending, committed).await?; + Ok(Vec::new()) + } + BodyLength::Chunked => { + let mut size_line = reader.read_line().await?; + loop { + let size_line_text = std::str::from_utf8(&size_line) + .map_err(|_| miette!("Invalid UTF-8 in response chunk-size line"))?; + let size_token = size_line_text + .split(';') + .next() + .map(str::trim) + .unwrap_or_default(); + let chunk_size = usize::from_str_radix(size_token, 16) + .map_err(|_| miette!("Invalid HTTP response chunk size"))?; + if chunk_size == 0 { + flush_normalized_response_bytes(session, client, pending, committed).await?; + return read_response_trailers(reader).await; + } + let mut remaining = chunk_size; + while remaining > 0 { + let length = remaining.min(unit_limit); + let unit = reader.read_exact_vec(length).await?; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + remaining -= unit.len(); + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + committed, + unit_limit, + ) + .await?; + } + if reader.read_exact_vec(2).await?.as_slice() != b"\r\n" { + return Err(miette!("HTTP response chunk is missing its terminator")); + } + size_line = if let Ok(line) = + tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, reader.read_line()).await + { + line? + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + committed, + ) + .await?; + reader.read_line().await? + }; + } + } + BodyLength::None if server_wants_close || event_stream => loop { + let read = reader.read_some(unit_limit); + let next = if pending.is_empty() && event_stream { + read.await? + } else if pending.is_empty() { + match tokio::time::timeout(RELAY_EOF_IDLE_TIMEOUT, read).await { + Ok(result) => result?, + Err(_) => None, + } + } else if let Ok(result) = + tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, read).await + { + result? + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + committed, + ) + .await?; + continue; + }; + let Some(unit) = next else { + flush_normalized_response_bytes(session, client, pending, committed).await?; + return Ok(Vec::new()); + }; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + committed, + unit_limit, + ) + .await?; + }, + BodyLength::None => { + flush_normalized_response_bytes(session, client, pending, committed).await?; + Ok(Vec::new()) + } + } +} + +async fn buffer_normalized_response_bytes( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + pending: &mut Vec, + data: Vec, + committed: bool, + unit_limit: usize, +) -> Result<()> { + pending.extend_from_slice(&data); + while pending.len() >= unit_limit { + let remainder = pending.split_off(unit_limit); + let unit = std::mem::replace(pending, remainder); + process_response_unit(session, client, unit, committed).await?; + } + Ok(()) +} + +async fn flush_normalized_response_bytes( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + pending: Vec, + committed: bool, +) -> Result<()> { + if pending.is_empty() { + return Ok(()); + } + process_response_unit(session, client, pending, committed).await +} + +async fn process_response_unit( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + unit: Vec, + committed: bool, +) -> Result<()> { + let output = session + .push_body(unit) + .await + .map_err(|error| miette!("HTTP response middleware failure: {error}"))?; + if committed { + for unit in output { + write_chunk(client, &unit).await?; + } + client.flush().await.into_diagnostic()?; + } else if !output.is_empty() { + return Err(miette!( + "whole-body response middleware released output before finalization" + )); + } + Ok(()) +} + +async fn read_response_trailers( + reader: &mut BufferedResponseReader<'_, R>, +) -> Result> { + let mut trailers = Vec::new(); + loop { + let line = reader.read_line().await?; + if line.is_empty() { + return Ok(trailers); + } + let line = std::str::from_utf8(&line) + .map_err(|_| miette!("HTTP response trailer contains invalid UTF-8"))?; + let (name, value) = line + .split_once(':') + .ok_or_else(|| miette!("Malformed HTTP response trailer"))?; + let name = name.to_ascii_lowercase(); + if matches!( + name.as_str(), + "connection" + | "content-length" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) { + continue; + } + trailers.push(HttpHeader { + name, + value: value.trim().to_string(), + }); + if trailers.len() > openshell_supervisor_middleware::MAX_MIDDLEWARE_HEADERS { + return Err(miette!("HTTP response trailer count exceeds limit")); + } + } +} + +async fn write_response_trailers( + client: &mut C, + trailers: &[HttpHeader], +) -> Result<()> { + client.write_all(b"0\r\n").await.into_diagnostic()?; + for trailer in trailers { + client + .write_all(format!("{}: {}\r\n", trailer.name, trailer.value).as_bytes()) + .await + .into_diagnostic()?; + } + client.write_all(b"\r\n").await.into_diagnostic()?; + Ok(()) +} + +async fn send_response_delivery_failure( + client: &mut C, + request_method: &str, + policy_name: &str, + target: &HttpRequestTarget, +) -> Result<()> { + let body = serde_json::to_vec(&serde_json::json!({ + "error": "response_delivery_failed", + "detail": "The upstream request may have completed, but OpenShell could not deliver its response. Retrying may repeat upstream side effects.", + "policy": policy_name, + "layer": "http_response_pre_return", + "method": target.method, + "path": target.path, + "host": target.host, + "port": target.port, + })) + .into_diagnostic()?; + let head = format!( + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-OpenShell-Policy: {policy_name}\r\nConnection: close\r\n\r\n", + body.len() + ); + client.write_all(head.as_bytes()).await.into_diagnostic()?; + if !request_method.eq_ignore_ascii_case("HEAD") { + client.write_all(&body).await.into_diagnostic()?; + } + client.flush().await.into_diagnostic()?; + Ok(()) +} + /// Parse the HTTP status code from a response status line. /// /// Expects the first line to look like `HTTP/1.1 200 OK`. @@ -3623,17 +4624,207 @@ mod tests { use crate::opa::OpaEngine; use flate2::{Compress, Compression, Decompress, FlushCompress, FlushDecompress, Status}; use openshell_core::proposals::AgentProposals; + use openshell_core::proto::{ + Decision, HttpRequestResult, HttpResponseBodyMode, HttpResponseBodyResult, + HttpResponseBodyTransform, HttpResponseEvent, HttpResponseEventResult, + HttpResponsePreflightDecision, HttpResponsePreflightInspect, HttpResponseTrailersResult, + MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, + SupervisorMiddlewarePhase, http_response_body_result, http_response_body_transform, + http_response_body_unit, http_response_event, http_response_event_result, + http_response_preflight_decision, + }; use openshell_core::secrets::SecretResolver; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; use tokio::io::ReadBuf; + use tokio::sync::mpsc; + use tokio_stream::wrappers::ReceiverStream; const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); const VALID_WS_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const VALID_WS_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; const TEXT_OPCODE: u8 = 0x1; + #[derive(Clone, Copy)] + enum ResponseRelayScript { + HeadersOnly, + WholeBody, + Stream, + InvalidBodySequence, + InvalidWholeBodySequence, + } + + struct ResponseRelayService { + script: ResponseRelayScript, + } + + #[tonic::async_trait] + impl openshell_supervisor_middleware::InProcessMiddleware for ResponseRelayService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/response-relay".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpResponse as i32, + phase: SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _request: openshell_supervisor_middleware::HttpRequestView<'_>, + ) -> Result { + Ok(HttpRequestResult { + decision: Decision::Allow as i32, + ..Default::default() + }) + } + + async fn open_http_response_pre_return( + &self, + mut requests: mpsc::Receiver, + ) -> std::result::Result< + openshell_supervisor_middleware::HttpResponseResultStream, + tonic::Status, + > { + let script = self.script; + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + let Some(event) = event.event else { + break; + }; + let result = match event { + http_response_event::Event::Preflight(_) => { + let (body_mode, header_mutations, declared_trailer_names) = match script + { + ResponseRelayScript::HeadersOnly => ( + HttpResponseBodyMode::HeadersOnly, + vec![write_header( + "cache-control", + "private", + ExistingHeaderAction::Overwrite, + )], + Vec::new(), + ), + ResponseRelayScript::WholeBody + | ResponseRelayScript::InvalidWholeBodySequence => { + (HttpResponseBodyMode::WholeBodyBytes, Vec::new(), Vec::new()) + } + ResponseRelayScript::Stream + | ResponseRelayScript::InvalidBodySequence => ( + HttpResponseBodyMode::StreamBytes, + Vec::new(), + vec!["digest".into()], + ), + }; + HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightDecision( + HttpResponsePreflightDecision { + decision: Some( + http_response_preflight_decision::Decision::Inspect( + HttpResponsePreflightInspect { + body_mode: body_mode as i32, + header_mutations, + declared_trailer_names, + ..Default::default() + }, + ), + ), + }, + ), + ), + } + } + http_response_event::Event::Body(body) => { + let Some(http_response_body_unit::Payload::Data(data)) = body.payload + else { + break; + }; + let replacement = match script { + ResponseRelayScript::WholeBody + | ResponseRelayScript::InvalidWholeBodySequence => { + [b"whole:".as_slice(), &data].concat() + } + ResponseRelayScript::Stream + | ResponseRelayScript::InvalidBodySequence => { + data.to_ascii_uppercase() + } + ResponseRelayScript::HeadersOnly => break, + }; + HttpResponseEventResult { + result: Some(http_response_event_result::Result::BodyResult( + HttpResponseBodyResult { + sequence: if matches!( + script, + ResponseRelayScript::InvalidBodySequence + | ResponseRelayScript::InvalidWholeBodySequence + ) { + body.sequence + 1 + } else { + body.sequence + }, + decision: Some( + http_response_body_result::Decision::Transform( + HttpResponseBodyTransform { + replacement: Some( + http_response_body_transform::Replacement::Data( + replacement, + ), + ), + }, + ), + ), + ..Default::default() + }, + )), + } + } + http_response_event::Event::Trailers(_) => HttpResponseEventResult { + result: Some(http_response_event_result::Result::TrailersResult( + HttpResponseTrailersResult { + trailer_mutations: if matches!( + script, + ResponseRelayScript::Stream + ) { + vec![write_header( + "digest", + "sha-256=:test:", + ExistingHeaderAction::Overwrite, + )] + } else { + Vec::new() + }, + ..Default::default() + }, + )), + }, + http_response_event::Event::BodyEnd(_) => continue, + http_response_event::Event::SessionEnd(_) => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) + } + } + struct CountingReader { bytes: Vec, position: usize, @@ -5503,6 +6694,273 @@ mod tests { assert!(!is_bodiless_response("POST", 201)); } + fn response_middleware_fixture( + script: ResponseRelayScript, + ) -> ( + openshell_supervisor_middleware::ChainRunner, + Vec, + ) { + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { + script, + })); + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "response".into(), + implementation: "test/response-relay".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + (runner, chain) + } + + fn response_middleware_context<'a>( + runner: &'a openshell_supervisor_middleware::ChainRunner, + chain: &'a [openshell_supervisor_middleware::ChainEntry], + method: &str, + ) -> HttpResponseMiddlewareRelay<'a> { + HttpResponseMiddlewareRelay { + chain, + runner, + request_context: RequestContext { + request_id: "request-1".into(), + sandbox_id: "sandbox-1".into(), + ..Default::default() + }, + target: HttpRequestTarget { + scheme: "https".into(), + host: "example.test".into(), + port: 443, + method: method.into(), + path: "/data".into(), + query: String::new(), + }, + policy_name: "test-policy", + generation_guard: None, + } + } + + async fn run_response_middleware_relay( + response: &'static [u8], + method: &str, + script: ResponseRelayScript, + ) -> (Result, Vec) { + let (runner, chain) = response_middleware_fixture(script); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(16 * 1024); + let (mut client_read, mut client_write) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + upstream_write.write_all(response).await.unwrap(); + upstream_write.shutdown().await.unwrap(); + }); + let outcome = relay_response( + method, + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(response_middleware_context(&runner, &chain, method)), + ) + .await; + drop(client_write); + let mut delivered = Vec::new(); + client_read.read_to_end(&mut delivered).await.unwrap(); + (outcome, delivered) + } + + #[tokio::test] + async fn response_middleware_headers_only_mutates_head_and_repairs_framing() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nCache-Control: public\r\n\r\nhello", + "GET", + ResponseRelayScript::HeadersOnly, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains("cache-control: private\r\n"), + "{delivered}" + ); + assert!( + delivered.contains("Transfer-Encoding: chunked\r\n"), + "{delivered}" + ); + assert!( + delivered.ends_with("5\r\nhello\r\n0\r\n\r\n"), + "{delivered}" + ); + } + + #[tokio::test] + async fn response_middleware_whole_body_delays_commit_and_sets_length() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nETag: stale\r\n\r\nhello", + "GET", + ResponseRelayScript::WholeBody, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.contains("Content-Length: 11\r\n"), "{delivered}"); + assert!( + !delivered.to_ascii_lowercase().contains("etag:"), + "{delivered}" + ); + assert!(delivered.ends_with("\r\n\r\nwhole:hello"), "{delivered}"); + } + + #[tokio::test] + async fn response_middleware_streams_normalized_chunk_payloads_and_trailers() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nTrailer: x-upstream\r\n\r\n2;ext=yes\r\nhe\r\n3\r\nllo\r\n0\r\nX-Upstream: kept\r\n\r\n", + "GET", + ResponseRelayScript::Stream, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains("Trailer: x-upstream, digest\r\n"), + "{delivered}" + ); + assert!(delivered.contains("5\r\nHELLO\r\n"), "{delivered}"); + assert!(delivered.contains("x-upstream: kept\r\n"), "{delivered}"); + assert!( + delivered.contains("digest: sha-256=:test:\r\n"), + "{delivered}" + ); + assert!(!delivered.contains("ext=yes"), "{delivered}"); + } + + #[tokio::test] + async fn response_middleware_forwards_interim_head_before_final_preflight() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 100 Continue\r\nX-Interim: yes\r\n\r\nHTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok", + "GET", + ResponseRelayScript::WholeBody, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.starts_with("HTTP/1.1 100 Continue\r\nX-Interim: yes\r\n\r\n")); + assert!(delivered.ends_with("whole:ok"), "{delivered}"); + } + + #[tokio::test] + async fn response_middleware_fail_closed_before_commit_returns_canonical_502() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + ResponseRelayScript::InvalidWholeBodySequence, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.starts_with("HTTP/1.1 502 Bad Gateway\r\n"), + "{delivered}" + ); + assert!(delivered.contains("\"error\":\"response_delivery_failed\"")); + assert!(delivered.contains( + "The upstream request may have completed, but OpenShell could not deliver its response. Retrying may repeat upstream side effects." + )); + assert!(!delivered.contains("invalid_body_sequence"), "{delivered}"); + } + + #[tokio::test] + async fn response_middleware_head_failure_reports_body_length_without_body() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n", + "HEAD", + ResponseRelayScript::WholeBody, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let split = delivered + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + let head = String::from_utf8(delivered[..split].to_vec()).unwrap(); + assert!(head.starts_with("HTTP/1.1 502 Bad Gateway\r\n"), "{head}"); + assert!(head.contains("Content-Length: "), "{head}"); + assert_eq!(&delivered[split..], b""); + } + + #[tokio::test] + async fn response_middleware_fail_closed_after_commit_aborts_without_replacement() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + ResponseRelayScript::InvalidBodySequence, + ) + .await; + assert!(outcome.is_err()); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.starts_with("HTTP/1.1 200 OK\r\n"), "{delivered}"); + assert!(!delivered.contains("502 Bad Gateway"), "{delivered}"); + } + + #[tokio::test] + async fn response_middleware_streams_close_delimited_body_with_owned_framing() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nhello", + "GET", + ResponseRelayScript::Stream, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains("Transfer-Encoding: chunked\r\n"), + "{delivered}" + ); + assert!(delivered.contains("5\r\nHELLO\r\n"), "{delivered}"); + } + + #[test] + fn response_middleware_ocsf_events_omit_content_headers_and_free_form_reasons() { + let target = HttpRequestTarget { + scheme: "https".into(), + host: "example.test".into(), + port: 443, + method: "GET".into(), + path: "/safe".into(), + query: String::new(), + }; + let events = http_response_middleware_invocation_events( + "policy", + &target, + 200, + &[openshell_supervisor_middleware::HttpResponseInvocation { + config_name: "scan".into(), + implementation: "example/scan".into(), + outcome: openshell_supervisor_middleware::HttpResponseInvocationOutcome::FailOpen, + sequence: Some(1), + input_size: 19, + output_size: None, + failed: true, + stage_disabled: true, + reason_code: Some("stable_reason".into()), + }], + ); + let json = events[0].to_json().unwrap().to_string(); + for forbidden in [ + "secret-response-body", + "authorization", + "content-length", + "middleware said secret", + "stable_reason", + ] { + assert!(!json.contains(forbidden), "{json}"); + } + assert!( + json.to_ascii_lowercase() + .contains("http_response_middleware"), + "{json}" + ); + assert!(json.contains("example/scan"), "{json}"); + } + #[tokio::test] async fn relay_response_no_framing_with_connection_close_reads_until_eof() { // Response with Connection: close but no Content-Length/TE: body is @@ -5524,6 +6982,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5569,6 +7028,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5619,6 +7079,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5663,6 +7124,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5704,6 +7166,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5742,6 +7205,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5782,6 +7246,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5826,6 +7291,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5869,6 +7335,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5905,6 +7372,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5951,6 +7419,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5998,6 +7467,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index dc2736a4ea..2e3db78707 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1751,7 +1751,7 @@ async fn handle_tcp_connection( let target = parts.next().unwrap_or(""); if method != "CONNECT" { - return handle_forward_proxy( + return Box::pin(handle_forward_proxy( method, target, &buf[..], @@ -1768,7 +1768,7 @@ async fn handle_tcp_connection( dynamic_credentials, denial_tx.as_ref(), activity_tx.as_ref(), - ) + )) .await; } @@ -6630,7 +6630,7 @@ network_policies: tokio::time::timeout( std::time::Duration::from_secs(30), - handle_forward_proxy( + Box::pin(handle_forward_proxy( "GET", &target, request.as_bytes(), @@ -6647,7 +6647,7 @@ network_policies: None, None, None, - ), + )), ) .await .expect("denied preflight must complete without an upstream response") @@ -6763,7 +6763,7 @@ network_policies: let (mut proxy_connection, _) = proxy_listener.accept().await.unwrap(); let handler = tokio::spawn(async move { - handle_forward_proxy( + Box::pin(handle_forward_proxy( "GET", &target, request.as_bytes(), @@ -6780,7 +6780,7 @@ network_policies: None, None, None, - ) + )) .await }); let scenario = tokio::time::timeout(std::time::Duration::from_secs(60), async { From 8600decf64a405329bd78e1e8a9077e239c44913 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 31 Aug 2026 19:54:00 -0700 Subject: [PATCH 4/6] feat(middleware): add response transform example Signed-off-by: Piotr Mlocek --- .github/workflows/branch-checks.yml | 2 + .../Cargo.lock | 2249 +++++++++++++++++ .../Cargo.toml | 27 + .../README.md | 76 + .../policy.yaml | 34 + .../src/main.rs | 463 ++++ .../upstream.py | 51 + tasks/rust.toml | 3 + 8 files changed, 2905 insertions(+) create mode 100644 examples/supervisor-middleware-response-transform/Cargo.lock create mode 100644 examples/supervisor-middleware-response-transform/Cargo.toml create mode 100644 examples/supervisor-middleware-response-transform/README.md create mode 100644 examples/supervisor-middleware-response-transform/policy.yaml create mode 100644 examples/supervisor-middleware-response-transform/src/main.rs create mode 100644 examples/supervisor-middleware-response-transform/upstream.py diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 2d9dc9914f..d18b8a2d13 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -154,12 +154,14 @@ jobs: cargo fmt --all -- --check cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check + cargo fmt --manifest-path examples/supervisor-middleware-response-transform/Cargo.toml --all -- --check - name: Lint run: | cargo clippy --workspace --all-targets -- -D warnings cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets + cargo check --manifest-path examples/supervisor-middleware-response-transform/Cargo.toml --all-targets - name: Test env: diff --git a/examples/supervisor-middleware-response-transform/Cargo.lock b/examples/supervisor-middleware-response-transform/Cargo.lock new file mode 100644 index 0000000000..e3b618639e --- /dev/null +++ b/examples/supervisor-middleware-response-transform/Cargo.lock @@ -0,0 +1,2249 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "glob", + "ipnet", + "miette", + "nix", + "openshell-extension-core", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "hickory-proto", + "miette", + "openshell-core", + "prost-types", + "serde", + "serde_json", + "serde_yml", +] + +[[package]] +name = "openshell-supervisor-middleware-response-transform" +version = "0.0.0" +dependencies = [ + "clap", + "openshell-core", + "openshell-policy", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/supervisor-middleware-response-transform/Cargo.toml b/examples/supervisor-middleware-response-transform/Cargo.toml new file mode 100644 index 0000000000..f609f31811 --- /dev/null +++ b/examples/supervisor-middleware-response-transform/Cargo.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "openshell-supervisor-middleware-response-transform" +description = "Example OpenShell HTTP response middleware service" +version = "0.0.0" +edition = "2024" +rust-version = "1.90" +license = "Apache-2.0" +publish = false + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +openshell-core = { path = "../../crates/openshell-core", default-features = false } +tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] } +tokio-stream = "0.1" +tonic = { version = "0.14", features = ["transport"] } + +[dev-dependencies] +openshell-policy = { path = "../../crates/openshell-policy" } + +[[bin]] +name = "supervisor-middleware-response-transform" +path = "src/main.rs" diff --git a/examples/supervisor-middleware-response-transform/README.md b/examples/supervisor-middleware-response-transform/README.md new file mode 100644 index 0000000000..5b2156e571 --- /dev/null +++ b/examples/supervisor-middleware-response-transform/README.md @@ -0,0 +1,76 @@ + + +# HTTP Response Transform Middleware + +> [!WARNING] +> Supervisor middleware is a research preview. Its policy and service contracts may change without compatibility guarantees. Use it only to prototype and evaluate middleware integrations. + +This operator-run service demonstrates all three `HTTP_RESPONSE/PRE_RETURN` body modes. It selects a mode from the request path, changes one response header, and transforms the upstream response before the sandbox receives it. + +| Path | Middleware mode | Result | +| --- | --- | --- | +| `/headers-only` | `HEADERS_ONLY` | Adds `x-example-response-mode` and passes the body through. | +| `/whole-body` | `WHOLE_BODY_BYTES` | Buffers the normalized body and prefixes it with `[whole]` plus a space. | +| `/stream` | `STREAM_BYTES` | Uppercases each normalized unit and writes the declared `x-example-body-bytes` trailer. | + +Other paths return `SKIP` with the audit-safe reason code `path_not_selected`. + +## Run the Example + +Start the raw HTTP upstream. It deliberately returns one content-length response, one chunked response, and one close-delimited response: + +```shell +python3 examples/supervisor-middleware-response-transform/upstream.py +``` + +In another terminal, start the middleware service. Bind to all host interfaces so a containerized gateway and sandbox supervisor can reach it: + +```shell +cargo run \ + --manifest-path examples/supervisor-middleware-response-transform/Cargo.toml \ + -- \ + --bind 0.0.0.0:50052 +``` + +Register the service in the gateway TOML, then start or restart the gateway: + +```toml +[[openshell.supervisor.middleware]] +name = "response-transform-example" +grpc_endpoint = "http://host.openshell.internal:50052" +allow_insecure_transport = true +max_payload_bytes = 262144 +timeout = "500ms" +``` + +The plaintext endpoint has no peer authentication. Use it only for this local example. Both the gateway and sandbox supervisors must resolve and reach the configured hostname. + +Create a sandbox with the included policy: + +```shell +openshell sandbox create \ + --policy examples/supervisor-middleware-response-transform/policy.yaml +``` + +Run these commands inside the sandbox: + +```shell +curl -i http://host.openshell.internal:18081/headers-only +curl -i http://host.openshell.internal:18081/whole-body +curl -i --raw http://host.openshell.internal:18081/stream +``` + +The first response keeps the `headers-only` body unchanged. The second body becomes `[whole] whole body`. The third body becomes `STREAM BODY` and ends with `x-example-body-bytes: 11`. + +OpenShell repairs framing after middleware runs. The content-length response selected for headers-only inspection is delivered with streaming-compatible chunked framing, the upstream chunked body selected for whole-body inspection receives a recalculated `Content-Length`, and the close-delimited stream is delivered as chunked with its declared trailer. Middleware receives normalized representation bytes, never upstream transfer chunks or socket-read boundaries. + +## Test the Service + +```shell +cargo test \ + --manifest-path examples/supervisor-middleware-response-transform/Cargo.toml \ + --all-targets +``` diff --git a/examples/supervisor-middleware-response-transform/policy.yaml b/examples/supervisor-middleware-response-transform/policy.yaml new file mode 100644 index 0000000000..239ebaa1e0 --- /dev/null +++ b/examples/supervisor-middleware-response-transform/policy.yaml @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +network_middlewares: + response-transform: + name: Response transform example + middleware: response-transform-example + order: 10 + on_error: fail_closed + endpoints: + include: + - host.openshell.internal + +network_policies: + response-framing-demo: + name: Response framing demo + endpoints: + - host: host.openshell.internal + port: 18081 + protocol: rest + rules: + - allow: + method: GET + path: /headers-only + - allow: + method: GET + path: /whole-body + - allow: + method: GET + path: /stream + binaries: + - path: /usr/bin/curl diff --git a/examples/supervisor-middleware-response-transform/src/main.rs b/examples/supervisor-middleware-response-transform/src/main.rs new file mode 100644 index 0000000000..b26cba3123 --- /dev/null +++ b/examples/supervisor-middleware-response-transform/src/main.rs @@ -0,0 +1,463 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; + +use clap::Parser; +use openshell_core::middleware::{HttpResponseResultStream, WebSocketResponseStream}; +use openshell_core::proto::middleware::v1::http_response_pre_return_server::{ + HttpResponsePreReturn, HttpResponsePreReturnServer, +}; +use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ + SupervisorMiddleware, SupervisorMiddlewareServer, +}; +use openshell_core::proto::{ + Decision, ExistingHeaderAction, HeaderMutation, HttpRequestEvaluation, HttpRequestResult, + HttpResponseBodyMode, HttpResponseBodyResult, HttpResponseBodyTransform, HttpResponseEvent, + HttpResponseEventResult, HttpResponsePreflightDecision, HttpResponsePreflightInspect, + HttpResponsePreflightSkip, HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, + ValidateConfigResponse, WebSocketSessionEvent, WriteHeader, header_mutation, + http_response_body_result, http_response_body_transform, http_response_body_unit, + http_response_event, http_response_event_result, http_response_preflight_decision, +}; +use tokio::sync::mpsc; +use tokio_stream::StreamExt as _; +use tokio_stream::wrappers::ReceiverStream; +use tonic::transport::Server; +use tonic::{Request, Response, Status}; + +const MANIFEST_NAME: &str = "example/response-transform-service"; +const MAX_PAYLOAD_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Parser)] +#[command(about = "Run the example OpenShell HTTP response middleware")] +struct Cli { + /// Address on which to serve plaintext gRPC. + #[arg(long, default_value = "127.0.0.1:50052")] + bind: SocketAddr, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ResponseTransform; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SelectedMode { + HeadersOnly, + WholeBody, + Stream, +} + +#[derive(Debug, Default)] +struct SessionState { + selected: Option, + next_sequence: u64, + input_bytes: u64, +} + +impl SessionState { + fn preflight( + &mut self, + preflight: openshell_core::proto::HttpResponsePreflight, + ) -> Result { + if self.selected.is_some() { + return Err(Status::failed_precondition("duplicate response preflight")); + } + let path = preflight + .target + .as_ref() + .map(|target| target.path.as_str()) + .unwrap_or_default(); + let Some(selected) = mode_for_path(path) else { + return Ok(preflight_skip()); + }; + self.selected = Some(selected); + self.next_sequence = 1; + Ok(preflight_inspect(selected)) + } + + fn body( + &mut self, + body: openshell_core::proto::HttpResponseBodyUnit, + ) -> Result { + let selected = self + .selected + .ok_or_else(|| Status::failed_precondition("body arrived before preflight"))?; + if selected == SelectedMode::HeadersOnly { + return Err(Status::failed_precondition( + "headers-only sessions do not receive body events", + )); + } + if body.sequence != self.next_sequence { + return Err(Status::invalid_argument(format!( + "expected body sequence {}, received {}", + self.next_sequence, body.sequence + ))); + } + self.next_sequence = self.next_sequence.saturating_add(1); + let Some(http_response_body_unit::Payload::Data(data)) = body.payload else { + return Err(Status::invalid_argument("body data is required")); + }; + self.input_bytes = self.input_bytes.saturating_add(data.len() as u64); + let replacement = match selected { + SelectedMode::WholeBody => [b"[whole] ".as_slice(), &data].concat(), + SelectedMode::Stream => data.to_ascii_uppercase(), + SelectedMode::HeadersOnly => unreachable!(), + }; + Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::BodyResult( + HttpResponseBodyResult { + sequence: body.sequence, + decision: Some(http_response_body_result::Decision::Transform( + HttpResponseBodyTransform { + replacement: Some(http_response_body_transform::Replacement::Data( + replacement, + )), + }, + )), + ..Default::default() + }, + )), + }) + } + + fn body_end(&self, final_sequence: u64) -> Result<(), Status> { + let expected = self.next_sequence.saturating_sub(1); + if final_sequence != expected { + return Err(Status::invalid_argument(format!( + "expected final body sequence {expected}, received {final_sequence}" + ))); + } + Ok(()) + } + + fn trailers(&self) -> Result { + let selected = self + .selected + .ok_or_else(|| Status::failed_precondition("trailers arrived before preflight"))?; + if selected == SelectedMode::HeadersOnly { + return Err(Status::failed_precondition( + "headers-only sessions do not receive trailers", + )); + } + let trailer_mutations = if selected == SelectedMode::Stream { + vec![write_header( + "x-example-body-bytes", + &self.input_bytes.to_string(), + )] + } else { + Vec::new() + }; + Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::TrailersResult( + HttpResponseTrailersResult { + trailer_mutations, + ..Default::default() + }, + )), + }) + } +} + +fn mode_for_path(path: &str) -> Option { + match path { + "/headers-only" => Some(SelectedMode::HeadersOnly), + "/whole-body" => Some(SelectedMode::WholeBody), + "/stream" => Some(SelectedMode::Stream), + _ => None, + } +} + +fn preflight_skip() -> HttpResponseEventResult { + HttpResponseEventResult { + result: Some(http_response_event_result::Result::PreflightDecision( + HttpResponsePreflightDecision { + decision: Some(http_response_preflight_decision::Decision::Skip( + HttpResponsePreflightSkip { + reason: "path is outside the response-transform example".into(), + reason_code: "path_not_selected".into(), + ..Default::default() + }, + )), + }, + )), + } +} + +fn preflight_inspect(selected: SelectedMode) -> HttpResponseEventResult { + let (body_mode, declared_trailer_names) = match selected { + SelectedMode::HeadersOnly => (HttpResponseBodyMode::HeadersOnly, Vec::new()), + SelectedMode::WholeBody => (HttpResponseBodyMode::WholeBodyBytes, Vec::new()), + SelectedMode::Stream => ( + HttpResponseBodyMode::StreamBytes, + vec!["x-example-body-bytes".into()], + ), + }; + let mode_name = match selected { + SelectedMode::HeadersOnly => "headers-only", + SelectedMode::WholeBody => "whole-body", + SelectedMode::Stream => "stream", + }; + HttpResponseEventResult { + result: Some(http_response_event_result::Result::PreflightDecision( + HttpResponsePreflightDecision { + decision: Some(http_response_preflight_decision::Decision::Inspect( + HttpResponsePreflightInspect { + body_mode: body_mode as i32, + header_mutations: vec![write_header("x-example-response-mode", mode_name)], + declared_trailer_names, + ..Default::default() + }, + )), + }, + )), + } +} + +fn write_header(name: &str, value: &str) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Write(WriteHeader { + name: name.into(), + value: value.into(), + on_existing: ExistingHeaderAction::Overwrite as i32, + })), + } +} + +#[tonic::async_trait] +impl SupervisorMiddleware for ResponseTransform { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn describe( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(MiddlewareManifest { + name: MANIFEST_NAME.into(), + service_version: env!("CARGO_PKG_VERSION").into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpResponse as i32, + phase: SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: MAX_PAYLOAD_BYTES, + timeout: String::new(), + }], + expected_audience: String::new(), + })) + } + + async fn validate_config( + &self, + request: Request, + ) -> Result, Status> { + let valid = request + .get_ref() + .config + .as_ref() + .is_none_or(|config| config.fields.is_empty()); + Ok(Response::new(ValidateConfigResponse { + valid, + reason: if valid { + String::new() + } else { + "this example does not accept configuration fields".into() + }, + })) + } + + async fn evaluate_http_request( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(HttpRequestResult { + decision: Decision::Allow as i32, + ..Default::default() + })) + } + + async fn evaluate_web_socket_session( + &self, + _request: Request>, + ) -> Result, Status> { + Err(Status::unimplemented("HTTP response-only service")) + } +} + +#[tonic::async_trait] +impl HttpResponsePreReturn for ResponseTransform { + type EvaluateStream = HttpResponseResultStream; + + async fn evaluate( + &self, + request: Request>, + ) -> Result, Status> { + let mut events = request.into_inner(); + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + let mut state = SessionState::default(); + while let Some(event) = events.next().await { + let event = match event { + Ok(event) => event, + Err(error) => { + let _ = sender.send(Err(error)).await; + break; + } + }; + let result = match event.event { + Some(http_response_event::Event::Preflight(preflight)) => { + state.preflight(preflight).map(Some) + } + Some(http_response_event::Event::Body(body)) => state.body(body).map(Some), + Some(http_response_event::Event::BodyEnd(body_end)) => { + state.body_end(body_end.final_sequence).map(|()| None) + } + Some(http_response_event::Event::Trailers(_)) => state.trailers().map(Some), + Some(http_response_event::Event::SessionEnd(_)) => break, + None => Err(Status::invalid_argument("response event is required")), + }; + match result { + Ok(Some(result)) => { + if sender.send(Ok(result)).await.is_err() { + break; + } + } + Ok(None) => {} + Err(error) => { + let _ = sender.send(Err(error)).await; + break; + } + } + } + }); + Ok(Response::new(Box::pin(ReceiverStream::new(receiver)))) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + println!("response-transform middleware listening on {}", cli.bind); + Server::builder() + .add_service(SupervisorMiddlewareServer::new(ResponseTransform)) + .add_service(HttpResponsePreReturnServer::new(ResponseTransform)) + .serve(cli.bind) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{HttpRequestTarget, HttpResponseBodyUnit, HttpResponsePreflight}; + + fn preflight(path: &str) -> openshell_core::proto::HttpResponsePreflight { + HttpResponsePreflight { + target: Some(HttpRequestTarget { + path: path.into(), + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn paths_select_all_three_response_modes() { + for (path, expected) in [ + ("/headers-only", HttpResponseBodyMode::HeadersOnly), + ("/whole-body", HttpResponseBodyMode::WholeBodyBytes), + ("/stream", HttpResponseBodyMode::StreamBytes), + ] { + let mut state = SessionState::default(); + let result = state.preflight(preflight(path)).unwrap(); + let Some(http_response_event_result::Result::PreflightDecision(decision)) = + result.result + else { + panic!("expected preflight decision"); + }; + let Some(http_response_preflight_decision::Decision::Inspect(inspect)) = + decision.decision + else { + panic!("expected inspect decision"); + }; + assert_eq!(inspect.body_mode, expected as i32); + } + } + + #[test] + fn whole_body_and_stream_transform_differently() { + for (path, expected) in [ + ("/whole-body", b"[whole] hello".as_slice()), + ("/stream", b"HELLO".as_slice()), + ] { + let mut state = SessionState::default(); + state.preflight(preflight(path)).unwrap(); + let result = state + .body(HttpResponseBodyUnit { + sequence: 1, + payload: Some(http_response_body_unit::Payload::Data(b"hello".to_vec())), + }) + .unwrap(); + let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { + panic!("expected body result"); + }; + let Some(http_response_body_result::Decision::Transform(transform)) = body.decision + else { + panic!("expected body transform"); + }; + let Some(http_response_body_transform::Replacement::Data(data)) = transform.replacement + else { + panic!("expected replacement data"); + }; + assert_eq!(data, expected); + } + } + + #[test] + fn stream_declares_and_writes_byte_count_trailer() { + let mut state = SessionState::default(); + let preflight = state.preflight(preflight("/stream")).unwrap(); + let Some(http_response_event_result::Result::PreflightDecision(decision)) = + preflight.result + else { + panic!("expected preflight decision"); + }; + let Some(http_response_preflight_decision::Decision::Inspect(inspect)) = decision.decision + else { + panic!("expected inspect decision"); + }; + assert_eq!(inspect.declared_trailer_names, ["x-example-body-bytes"]); + state + .body(HttpResponseBodyUnit { + sequence: 1, + payload: Some(http_response_body_unit::Payload::Data(b"hello".to_vec())), + }) + .unwrap(); + state.body_end(1).unwrap(); + let trailers = state.trailers().unwrap(); + let Some(http_response_event_result::Result::TrailersResult(trailers)) = trailers.result + else { + panic!("expected trailer result"); + }; + assert_eq!(trailers.trailer_mutations.len(), 1); + } + + #[test] + fn paths_outside_the_demo_are_skipped() { + let mut state = SessionState::default(); + let result = state.preflight(preflight("/outside")).unwrap(); + let Some(http_response_event_result::Result::PreflightDecision(decision)) = result.result + else { + panic!("expected preflight decision"); + }; + let Some(http_response_preflight_decision::Decision::Skip(skip)) = decision.decision else { + panic!("expected skip decision"); + }; + assert_eq!(skip.reason_code, "path_not_selected"); + } + + #[test] + fn example_policy_is_valid() { + let policy = openshell_policy::parse_sandbox_policy(include_str!("../policy.yaml")) + .expect("example policy must parse"); + openshell_policy::validate_sandbox_policy(&policy).expect("example policy must be valid"); + } +} diff --git a/examples/supervisor-middleware-response-transform/upstream.py b/examples/supervisor-middleware-response-transform/upstream.py new file mode 100644 index 0000000000..566d7086f3 --- /dev/null +++ b/examples/supervisor-middleware-response-transform/upstream.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import socketserver + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + request = b"" + while b"\r\n\r\n" not in request: + block = self.request.recv(4096) + if not block: + return + request += block + path = request.split(b" ", 2)[1] + if path == b"/headers-only": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 12\r\n\r\n" + b"headers-only" + ) + elif path == b"/whole-body": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + b"6\r\nwhole \r\n4\r\nbody\r\n0\r\n\r\n" + ) + elif path == b"/stream": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Connection: close\r\n\r\n" + b"stream body" + ) + else: + response = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" + self.request.sendall(response) + + +class DemoServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + + +with DemoServer(("0.0.0.0", 18081), Handler) as server: + print("response framing demo upstream listening on 0.0.0.0:18081", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass diff --git a/tasks/rust.toml b/tasks/rust.toml index 854c2ac939..d520b90804 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -15,6 +15,7 @@ run = [ "cargo clippy --workspace --all-targets -- -D warnings", "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets", + "cargo check --manifest-path examples/supervisor-middleware-response-transform/Cargo.toml --all-targets", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -25,6 +26,7 @@ run = [ "cargo fmt --all", "cargo fmt --manifest-path e2e/rust/Cargo.toml --all", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all", + "cargo fmt --manifest-path examples/supervisor-middleware-response-transform/Cargo.toml --all", ] hide = true @@ -34,6 +36,7 @@ run = [ "cargo fmt --all -- --check", "cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check", + "cargo fmt --manifest-path examples/supervisor-middleware-response-transform/Cargo.toml --all -- --check", ] hide = true From 385436c119fc38d3a900503223a8b1caec15950e Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 31 Aug 2026 20:13:30 -0700 Subject: [PATCH 5/6] fix(middleware): harden response delivery semantics Signed-off-by: Piotr Mlocek --- .../src/response.rs | 367 ++++++++++++++++-- .../src/l7/rest.rs | 222 ++++++++++- 2 files changed, 540 insertions(+), 49 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 7c9f4ce5e2..a2fe8a7e8e 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -162,6 +162,8 @@ pub struct HttpResponseSession { invocations: Vec, session_admission: Option, body_transformed: bool, + defer_output_until_finish: bool, + deferred_output: Vec>, } impl HttpResponseSession { @@ -208,7 +210,13 @@ impl HttpResponseSession { reason: format!("middleware_failed: {error}"), })?; let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; - self.process_units_from(0, vec![data], deadline).await + let output = self.process_units_from(0, vec![data], deadline).await?; + if self.defer_output_until_finish { + self.deferred_output.extend(output); + Ok(Vec::new()) + } else { + Ok(output) + } } /// Finalize every body stage, process normalized trailers, and end streams. @@ -224,7 +232,7 @@ impl HttpResponseSession { reason: format!("middleware_failed: {error}"), })?; let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; - let mut released = Vec::new(); + let mut released = std::mem::take(&mut self.deferred_output); for index in 0..self.stages.len() { let stage_output = match self.finish_stage(index, deadline).await { Ok(output) => output, @@ -934,6 +942,9 @@ impl ChainRunner { session_capacity_exhausted: false, }); } + let defer_output_until_finish = stages + .iter() + .any(|stage| stage.is_active() && stage.mode == StageMode::WholeBody); Ok(HttpResponsePreflightOutcome { allowed: true, reason: String::new(), @@ -948,6 +959,8 @@ impl ChainRunner { invocations: Vec::new(), session_admission: Some(session_admission), body_transformed: false, + defer_output_until_finish, + deferred_output: Vec::new(), }), findings, metadata, @@ -1481,8 +1494,8 @@ mod tests { use openshell_core::proto::{ Decision, ExistingHeaderAction, HttpRequestResult, HttpResponseBodyResult, HttpResponseBodyTransform, HttpResponsePreflightDecision, HttpResponsePreflightInspect, - HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, WriteHeader, - http_response_preflight_decision, + HttpResponsePreflightSkip, HttpResponseTrailersResult, MiddlewareBinding, + MiddlewareManifest, WriteHeader, http_response_preflight_decision, }; use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::TcpListenerStream; @@ -1495,6 +1508,12 @@ mod tests { Stream, WholeBody, InvalidSequence, + Configured, + HangBody, + LargeStream, + Skip, + InvalidSkipReason, + UndeclaredTrailer, } struct ResponseService { @@ -1612,8 +1631,16 @@ mod tests { operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpResponse as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, - max_payload_bytes: 4096, - timeout: String::new(), + max_payload_bytes: if matches!(self.script, Script::LargeStream) { + 128 * 1024 + } else { + 4096 + }, + timeout: if matches!(self.script, Script::HangBody) { + "10ms".into() + } else { + String::new() + }, }], expected_audience: String::new(), } @@ -1644,29 +1671,90 @@ mod tests { let (sender, receiver) = mpsc::channel(4); let script = self.script; tokio::spawn(async move { + let mut selected_script = script; while let Some(event) = requests.recv().await { let Some(event) = event.event else { break; }; let result = match event { - http_response_event::Event::Preflight(_) => { - let (body_mode, header_mutations, declared_trailer_names) = match script - { - Script::HeadersOnly => ( - HttpResponseBodyMode::HeadersOnly, - vec![write_header("cache-control", "private")], - Vec::new(), - ), - Script::Stream | Script::InvalidSequence => ( - HttpResponseBodyMode::StreamBytes, - Vec::new(), - vec!["digest".into()], - ), - Script::WholeBody => { - (HttpResponseBodyMode::WholeBodyBytes, Vec::new(), Vec::new()) + http_response_event::Event::Preflight(preflight) => { + if matches!(script, Script::Configured) { + selected_script = match preflight + .config + .as_ref() + .and_then(|config| config.fields.get("mode")) + .and_then(|value| value.kind.as_ref()) + { + Some(prost_types::value::Kind::StringValue(mode)) + if mode == "whole" => + { + Script::WholeBody + } + Some(prost_types::value::Kind::StringValue(mode)) + if mode == "stream" => + { + Script::Stream + } + _ => Script::HeadersOnly, + }; + } + if matches!(selected_script, Script::Skip | Script::InvalidSkipReason) { + HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightDecision( + HttpResponsePreflightDecision { + decision: Some( + http_response_preflight_decision::Decision::Skip( + HttpResponsePreflightSkip { + reason: if matches!( + selected_script, + Script::InvalidSkipReason + ) { + "x".repeat( + MAX_MIDDLEWARE_REASON_BYTES + 1, + ) + } else { + "not selected".into() + }, + reason_code: "path_not_selected".into(), + ..Default::default() + }, + ), + ), + }, + ), + ), } - }; - HttpResponseEventResult { + } else { + let (body_mode, header_mutations, declared_trailer_names) = + match selected_script { + Script::HeadersOnly => ( + HttpResponseBodyMode::HeadersOnly, + vec![write_header("cache-control", "private")], + Vec::new(), + ), + Script::Stream | Script::InvalidSequence => ( + HttpResponseBodyMode::StreamBytes, + Vec::new(), + vec!["digest".into()], + ), + Script::HangBody + | Script::LargeStream + | Script::UndeclaredTrailer => ( + HttpResponseBodyMode::StreamBytes, + Vec::new(), + Vec::new(), + ), + Script::WholeBody => ( + HttpResponseBodyMode::WholeBodyBytes, + Vec::new(), + Vec::new(), + ), + Script::Configured + | Script::Skip + | Script::InvalidSkipReason => unreachable!(), + }; + HttpResponseEventResult { result: Some( http_response_event_result::Result::PreflightDecision( HttpResponsePreflightDecision { @@ -1684,23 +1772,35 @@ mod tests { ), ), } + } } http_response_event::Event::Body(body) => { + if matches!(selected_script, Script::HangBody) { + continue; + } let Some(http_response_body_unit::Payload::Data(data)) = body.payload else { break; }; - let replacement = match script { - Script::Stream | Script::InvalidSequence => { - data.to_ascii_uppercase() - } + let replacement = match selected_script { + Script::Stream + | Script::InvalidSequence + | Script::LargeStream + | Script::UndeclaredTrailer => data.to_ascii_uppercase(), Script::WholeBody => [b"whole:".as_slice(), &data].concat(), - Script::HeadersOnly => break, + Script::HeadersOnly + | Script::Configured + | Script::HangBody + | Script::Skip + | Script::InvalidSkipReason => break, }; HttpResponseEventResult { result: Some(http_response_event_result::Result::BodyResult( HttpResponseBodyResult { - sequence: if matches!(script, Script::InvalidSequence) { + sequence: if matches!( + selected_script, + Script::InvalidSequence + ) { body.sequence + 1 } else { body.sequence @@ -1724,7 +1824,10 @@ mod tests { http_response_event::Event::Trailers(_) => HttpResponseEventResult { result: Some(http_response_event_result::Result::TrailersResult( HttpResponseTrailersResult { - trailer_mutations: if matches!(script, Script::Stream) { + trailer_mutations: if matches!( + selected_script, + Script::Stream | Script::UndeclaredTrailer + ) { vec![write_header("digest", "sha-256=:test:")] } else { Vec::new() @@ -1780,6 +1883,24 @@ mod tests { } } + fn configured_entry(name: &str, order: i32, mode: &str) -> ChainEntry { + ChainEntry { + name: name.into(), + implementation: "test/response".into(), + order, + config: prost_types::Struct { + fields: [( + "mode".into(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue(mode.into())), + }, + )] + .into(), + }, + on_error: OnError::FailClosed, + } + } + fn input(status_code: u16) -> HttpResponsePreflightInput { HttpResponsePreflightInput { context: RequestContext { @@ -1893,16 +2014,67 @@ mod tests { } #[tokio::test] - async fn invalid_sequence_obeys_fail_open_and_fail_closed() { + async fn mixed_profile_chain_respects_policy_order_and_whole_body_barrier() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Configured, + })); + let entries = vec![ + configured_entry("stream", 20, "stream"), + configured_entry("whole", 10, "whole"), + ]; + let mut outcome = runner + .preflight_http_response(&entries, input(200)) + .await + .expect("mixed response preflight"); + let mut session = outcome.session.take().expect("mixed response session"); + assert!(session.requires_whole_body()); + assert!( + session + .push_body(b"hello".to_vec()) + .await + .expect("buffer mixed response") + .is_empty() + ); + let finish = session + .finish(Vec::new()) + .await + .expect("finish mixed chain"); + assert_eq!(finish.body_units, vec![b"WHOLE:HELLO".to_vec()]); + } + + #[tokio::test] + async fn whole_body_overflow_obeys_fail_open_and_fail_closed() { for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { let runner = ChainRunner::new(Arc::new(ResponseService { - script: Script::InvalidSequence, + script: Script::WholeBody, })); let mut outcome = runner .preflight_http_response(&[entry(on_error)], input(200)) .await - .expect("response preflight"); - let mut session = outcome.session.take().expect("stream session"); + .expect("whole-body response preflight"); + let mut session = outcome.session.take().expect("whole-body session"); + let original = vec![b'a'; 4097]; + let pushed = session.push_body(original.clone()).await; + assert_eq!(pushed.is_ok(), allowed); + if allowed { + assert!(pushed.unwrap().is_empty()); + let finish = session.finish(Vec::new()).await.expect("fail-open finish"); + assert_eq!(finish.body_units, vec![original]); + } + } + } + + #[tokio::test] + async fn response_body_timeout_obeys_fail_open_and_fail_closed() { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::HangBody, + })); + let mut outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("timed response preflight"); + let mut session = outcome.session.take().expect("timed response session"); let result = session.push_body(b"unchanged".to_vec()).await; assert_eq!(result.is_ok(), allowed); if let Ok(units) = result { @@ -1912,22 +2084,131 @@ mod tests { } #[tokio::test] - async fn partial_response_rejects_body_mode_through_on_error() { + async fn stream_unit_limit_never_exceeds_platform_cap() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::LargeStream, + })); + let mut outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("large stream preflight"); + let session = outcome.session.take().expect("large stream session"); + assert_eq!( + session.stream_unit_limit(), + MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES + ); + session + .finish(Vec::new()) + .await + .expect("finish large stream"); + } + + #[tokio::test] + async fn skip_reason_code_is_retained_and_oversized_reason_obeys_on_error() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Skip, + })); + let outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("skip response preflight"); + assert!(outcome.allowed); + assert!(outcome.session.is_none()); + assert_eq!( + outcome.invocations[0].reason_code.as_deref(), + Some("path_not_selected") + ); + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { let runner = ChainRunner::new(Arc::new(ResponseService { - script: Script::Stream, + script: Script::InvalidSkipReason, })); let outcome = runner - .preflight_http_response(&[entry(on_error)], input(206)) + .preflight_http_response(&[entry(on_error)], input(200)) .await - .expect("partial response preflight"); + .expect("invalid skip response preflight"); assert_eq!(outcome.allowed, allowed); assert!(outcome.session.is_none()); - if !allowed { - assert_eq!( - outcome.reason, - "middleware_failed: unsupported_partial_response" - ); + } + } + + #[tokio::test] + async fn undeclared_response_trailer_obeys_on_error() { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::UndeclaredTrailer, + })); + let mut outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("trailer response preflight"); + let mut session = outcome.session.take().expect("trailer response session"); + session + .push_body(b"body".to_vec()) + .await + .expect("transform response body"); + let finish = session.finish(Vec::new()).await; + assert_eq!(finish.is_ok(), allowed); + if let Ok(finish) = finish { + assert!(finish.trailers.is_empty()); + } + } + } + + #[tokio::test] + async fn invalid_sequence_obeys_fail_open_and_fail_closed() { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::InvalidSequence, + })); + let mut outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("response preflight"); + let mut session = outcome.session.take().expect("stream session"); + let result = session.push_body(b"unchanged".to_vec()).await; + assert_eq!(result.is_ok(), allowed); + if let Ok(units) = result { + assert_eq!(units, vec![b"unchanged".to_vec()]); + } + } + } + + #[tokio::test] + async fn body_inspection_restrictions_obey_fail_open_and_fail_closed() { + let mut cases = Vec::new(); + cases.push(input(206)); + for (name, value) in [ + ("content-range", "bytes 0-3/10"), + ("content-type", "multipart/byteranges; boundary=test"), + ("cache-control", "private, no-transform"), + ("content-encoding", "gzip"), + ] { + let mut candidate = input(200); + candidate.headers.push(HttpHeader { + name: name.into(), + value: value.into(), + }); + cases.push(candidate); + } + for status in [204, 304] { + cases.push(input(status)); + } + let mut head = input(200); + head.target.method = "HEAD".into(); + cases.push(head); + + for candidate in cases { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Stream, + })); + let outcome = runner + .preflight_http_response(&[entry(on_error)], candidate.clone()) + .await + .expect("restricted response preflight"); + assert_eq!(outcome.allowed, allowed); + assert!(outcome.session.is_none()); } } } diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 49c222bdba..0d0470df30 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -3531,8 +3531,18 @@ where server_wants_close, &declared_trailers, ); - client.write_all(&head).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; + if let Err(error) = client.write_all(&head).await { + session + .end(openshell_core::proto::HttpResponseSessionEndReason::ClientDisconnect) + .await; + return Err(error).into_diagnostic(); + } + if let Err(error) = client.flush().await { + session + .end(openshell_core::proto::HttpResponseSessionEndReason::ClientDisconnect) + .await; + return Err(error).into_diagnostic(); + } true }; @@ -3557,6 +3567,11 @@ where .is_some_and(PolicyGenerationGuard::is_stale) { openshell_core::proto::HttpResponseSessionEndReason::PolicyReload + } else if error + .to_string() + .starts_with("HTTP response client write failed:") + { + openshell_core::proto::HttpResponseSessionEndReason::ClientDisconnect } else if error .to_string() .starts_with("HTTP response middleware failure:") @@ -4198,9 +4213,14 @@ async fn process_response_unit( .map_err(|error| miette!("HTTP response middleware failure: {error}"))?; if committed { for unit in output { - write_chunk(client, &unit).await?; + write_chunk(client, &unit) + .await + .map_err(|error| miette!("HTTP response client write failed: {error}"))?; } - client.flush().await.into_diagnostic()?; + client + .flush() + .await + .map_err(|error| miette!("HTTP response client write failed: {error}"))?; } else if !output.is_empty() { return Err(miette!( "whole-body response middleware released output before finalization" @@ -6699,6 +6719,19 @@ mod tests { ) -> ( openshell_supervisor_middleware::ChainRunner, Vec, + ) { + response_middleware_fixture_with_error( + script, + openshell_supervisor_middleware::OnError::FailClosed, + ) + } + + fn response_middleware_fixture_with_error( + script: ResponseRelayScript, + on_error: openshell_supervisor_middleware::OnError, + ) -> ( + openshell_supervisor_middleware::ChainRunner, + Vec, ) { let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { @@ -6709,7 +6742,7 @@ mod tests { implementation: "test/response-relay".into(), order: 0, config: prost_types::Struct::default(), - on_error: openshell_supervisor_middleware::OnError::FailClosed, + on_error, }]; (runner, chain) } @@ -6745,7 +6778,22 @@ mod tests { method: &str, script: ResponseRelayScript, ) -> (Result, Vec) { - let (runner, chain) = response_middleware_fixture(script); + run_response_middleware_relay_with_error( + response, + method, + script, + openshell_supervisor_middleware::OnError::FailClosed, + ) + .await + } + + async fn run_response_middleware_relay_with_error( + response: &'static [u8], + method: &str, + script: ResponseRelayScript, + on_error: openshell_supervisor_middleware::OnError, + ) -> (Result, Vec) { + let (runner, chain) = response_middleware_fixture_with_error(script, on_error); let (mut upstream_read, mut upstream_write) = tokio::io::duplex(16 * 1024); let (mut client_read, mut client_write) = tokio::io::duplex(16 * 1024); tokio::spawn(async move { @@ -6845,6 +6893,55 @@ mod tests { assert!(delivered.ends_with("whole:ok"), "{delivered}"); } + #[tokio::test] + async fn response_middleware_handles_bodyless_responses_without_body_events() { + for response in [ + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n".as_slice(), + b"HTTP/1.1 304 Not Modified\r\nContent-Length: 5\r\n\r\n".as_slice(), + ] { + let (outcome, delivered) = + run_response_middleware_relay(response, "GET", ResponseRelayScript::HeadersOnly) + .await; + assert!(outcome.is_ok()); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains("cache-control: private\r\n"), + "{delivered}" + ); + assert!(delivered.ends_with("\r\n\r\n"), "{delivered}"); + } + + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n", + "HEAD", + ResponseRelayScript::HeadersOnly, + ) + .await; + assert!(outcome.is_ok()); + let split = delivered + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + assert_eq!(&delivered[split..], b""); + } + + #[tokio::test] + async fn response_middleware_bypasses_protocol_upgrades() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n\x81\x02ok", + "GET", + ResponseRelayScript::HeadersOnly, + ) + .await; + assert!(matches!( + outcome.unwrap(), + RelayOutcome::Upgraded { ref overflow, .. } if overflow == b"\x81\x02ok" + )); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(!delivered.contains("cache-control: private"), "{delivered}"); + } + #[tokio::test] async fn response_middleware_fail_closed_before_commit_returns_canonical_502() { let (outcome, delivered) = run_response_middleware_relay( @@ -6900,6 +6997,88 @@ mod tests { assert!(!delivered.contains("502 Bad Gateway"), "{delivered}"); } + #[tokio::test] + async fn response_middleware_fail_open_preserves_input_before_and_after_commit() { + for (script, expected_framing) in [ + ( + ResponseRelayScript::InvalidWholeBodySequence, + "Content-Length: 5\r\n", + ), + ( + ResponseRelayScript::InvalidBodySequence, + "Transfer-Encoding: chunked\r\n", + ), + ] { + let (outcome, delivered) = run_response_middleware_relay_with_error( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + script, + openshell_supervisor_middleware::OnError::FailOpen, + ) + .await; + assert!(outcome.is_ok()); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.contains(expected_framing), "{delivered}"); + assert!(delivered.contains("hello"), "{delivered}"); + assert!(!delivered.contains("502 Bad Gateway"), "{delivered}"); + } + } + + #[tokio::test] + async fn response_middleware_stale_policy_generation_aborts_before_preflight() { + let policy_data = "network_policies: {}\n"; + let engine = OpaEngine::from_strings(TEST_POLICY, policy_data).unwrap(); + let guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + engine.reload(TEST_POLICY, policy_data).unwrap(); + let (runner, chain) = response_middleware_fixture(ResponseRelayScript::Stream); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(4096); + let (mut client_read, mut client_write) = tokio::io::duplex(4096); + upstream_write + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello") + .await + .unwrap(); + upstream_write.shutdown().await.unwrap(); + let mut context = response_middleware_context(&runner, &chain, "GET"); + context.generation_guard = Some(&guard); + let outcome = relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(context), + ) + .await; + assert!(outcome.is_err()); + drop(client_write); + let mut delivered = Vec::new(); + client_read.read_to_end(&mut delivered).await.unwrap(); + assert!(delivered.is_empty()); + } + + #[tokio::test] + async fn response_middleware_client_disconnect_aborts_stream_delivery() { + let (runner, chain) = response_middleware_fixture(ResponseRelayScript::Stream); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(4096); + let (client_read, mut client_write) = tokio::io::duplex(4096); + drop(client_read); + upstream_write + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello") + .await + .unwrap(); + upstream_write.shutdown().await.unwrap(); + let outcome = relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(response_middleware_context(&runner, &chain, "GET")), + ) + .await; + assert!(outcome.is_err()); + } + #[tokio::test] async fn response_middleware_streams_close_delimited_body_with_owned_framing() { let (outcome, delivered) = run_response_middleware_relay( @@ -9419,4 +9598,35 @@ mod tests { SigV4PayloadMode::UnsignedPayload ); } + + #[test] + fn response_body_transform_strips_stale_integrity_headers() { + let mut headers = [ + "accept-ranges", + "etag", + "content-md5", + "digest", + "content-digest", + "repr-digest", + "signature", + "signature-input", + "content-type", + ] + .into_iter() + .map(|name| HttpHeader { + name: name.to_string(), + value: "value".to_string(), + }) + .collect(); + + strip_response_integrity_headers(&mut headers); + + assert_eq!( + headers, + vec![HttpHeader { + name: "content-type".to_string(), + value: "value".to_string(), + }] + ); + } } From 8768b1498a8c4c16e461b1737bed50b61795dd04 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 31 Aug 2026 20:15:14 -0700 Subject: [PATCH 6/6] docs(middleware): document response pre-return contract Signed-off-by: Piotr Mlocek --- architecture/security-policy.md | 15 ++ docs/extensibility/supervisor-middleware.mdx | 54 ++++-- docs/reference/gateway-config.mdx | 6 +- docs/reference/policy-schema.mdx | 6 +- docs/sandboxes/policies.mdx | 6 +- rfc/0009-supervisor-middleware/README.md | 178 +++++++++++------- .../appendices/extension-authentication.md | 2 +- .../appendices/protocol-extensions.md | 26 ++- 8 files changed, 186 insertions(+), 107 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 9203ba3178..8b6169f689 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -97,6 +97,21 @@ raw relay by default. A `protocol: rest` endpoint can opt in to after an allowed `101` upgrade; server-to-client traffic and all other upgraded protocols remain raw passthrough. +Supervisor middleware attaches independently of the network rule that admits +the destination. Request and client WebSocket hooks run after policy admission +and before credential injection. HTTP response hooks run after the upstream +returns a final non-`1xx` head and before the sandbox receives it. Response +middleware operates on normalized representation bytes, not transfer chunks or +socket reads, and retains each V1 input until the stage acknowledges it. This +lets `fail_open` preserve content. A `fail_closed` error returns a platform-owned +502 before response commitment and aborts the stream after commitment. Generic +response processing never handles a `101` protocol upgrade. + +One shared header-mutation validator applies request, response, and trailer +authority profiles atomically. Middleware can write permitted end-to-end fields +without a namespace prefix, while each direction protects its credentials, +routing, framing, connection control, and semantic security fields. + ## Credentialed Endpoints OpenShell keeps provider credentials on paths it can inspect or rewrite by diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index 3c0483dcd1..6462d3f470 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -3,11 +3,11 @@ # SPDX-License-Identifier: Apache-2.0 title: "Supervisor Middleware" sidebar-title: "Supervisor Middleware" -description: "Configure and operate built-in and operator-run middleware for sandbox HTTP requests and WebSocket messages." -keywords: "Generative AI, Cybersecurity, AI Agents, Supervisor Middleware, Extensibility, Request Filtering" +description: "Configure and operate built-in and operator-run middleware for sandbox HTTP requests, HTTP responses, and WebSocket messages." +keywords: "Generative AI, Cybersecurity, AI Agents, Supervisor Middleware, Extensibility, Request Filtering, Response Filtering" --- -Supervisor middleware adds ordered processing stages to allowed HTTP and WebSocket egress. Middleware runs after network and L7 policy admit traffic and before OpenShell injects provider credentials. A stage can allow or deny an HTTP request or client WebSocket text message, replace its payload, add approved HTTP headers, and report audit-safe findings. +Supervisor middleware adds ordered processing stages to allowed HTTP and WebSocket egress. Request middleware runs after network and L7 policy admit traffic and before OpenShell injects provider credentials. Response middleware runs after the upstream returns a final response and before OpenShell delivers it to the sandbox. A stage can transform request or response content, mutate permitted HTTP headers, and report audit-safe findings. Request and WebSocket hooks can also deny traffic; V1 response middleware has no explicit body-denial decision. Middleware selection is independent of the network policy rule that admitted the request. OpenShell matches middleware by destination host, so the same middleware applies consistently across broad, specific, user-authored, and provider-derived network policies. @@ -22,6 +22,19 @@ For each inspected HTTP request, the supervisor: 5. Re-checks body-aware protocol policy (GraphQL, JSON-RPC, MCP) after each stage that replaces the body. Every middleware receives a payload the policy admits, and a transformation cannot smuggle a denied or unparseable operation to a later stage or the upstream. 6. Applies allowed transformations, injects provider credentials, and forwards the request. +## Response Flow + +For each selected HTTP response chain, the supervisor: + +1. Relays interim `1xx` responses unchanged and retains the final non-`1xx` response head. A `101` protocol upgrade bypasses generic response processing. +2. Runs `HttpResponsePreReturn.Evaluate` preflight once per matching `HTTP_RESPONSE/PRE_RETURN` stage in policy order. Each stage sees earlier response-header mutations and returns `SKIP` or `INSPECT`. +3. Applies response-specific restrictions. A stage may choose `HEADERS_ONLY`, `WHOLE_BODY_BYTES`, or `STREAM_BYTES`. Bodyless responses, partial responses, non-identity content encodings, and responses with `Cache-Control: no-transform` permit headers-only inspection but reject body inspection through the stage's `on_error` policy. +4. Normalizes representation bytes independently of HTTP transfer chunks and socket reads. Whole-body stages receive one bounded body unit at the end. Streaming stages receive ordered units of at most 64 KiB and acknowledge each unit with `PASS_THROUGH` or `TRANSFORM` before OpenShell reads and releases more data. +5. Sends `body_end`, then normalized trailers, to body-inspecting stages. A stage may add only trailer names it declared during preflight. +6. Removes stale range and integrity metadata after body transformation, repairs downstream framing, and delivers the result. Buffered output uses a recalculated `Content-Length` unless trailers require chunked framing; streaming output uses middleware-owned chunked framing. + +OpenShell keeps a stable request ID across the request and response hooks for one exchange. Response streams have no whole-response deadline. Preflight and each unit acknowledgement use the effective binding timeout, and each unit's complete middleware chain is capped at 30 seconds. + For an RFC 6455 upgrade over `ws://` or `wss://`, the supervisor first finds every host-matched attachment, then selects only implementations that advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It opens one ordered, phase-specific `EvaluateWebSocketSession` stream per selected stage. OpenShell sends `WebSocketSessionEvent` values, while the service returns `WebSocketSessionEventResult` values only for preflight and message events; session start and end are notifications. Future upstream-to-client inspection uses the same RPC with `PRE_RETURN`; an implementation that advertises both phases receives two independent streams for the WebSocket session. An attachment without the selected binding can still inspect the HTTP upgrade request when it advertises the HTTP binding, but it is not a failed WebSocket stage. OpenShell allows post-upgrade traffic and emits an informational `binding_not_selected` coverage event for that attachment. 1. A preflight before the upgrade is sent upstream. The stage chooses `INSPECT`, voluntary `SKIP`, or authoritative `DENY` and may return a bounded diagnostic reason, stable reason code, findings, and metadata. OpenShell runs selected preflights concurrently; any `DENY` rejects the upgrade regardless of `on_error`. @@ -33,7 +46,7 @@ The protobuf represents each logical message with a `text` or `binary` payload v The network supervisor reserves process-wide assembly capacity before buffering every parsed WebSocket text message, even when no middleware is selected. At most 32 assemblies run while 64 additional callers wait without buffering payload bytes. When both bounds are full, OpenShell closes the WebSocket with code `1013` before reading the new message payload. A text message may contain at most 4,096 fragments, must make input progress within 30 seconds, and must finish assembly within 2 minutes. Forwarding the completed text frame must finish within another 2 minutes. The assembly budget lasts for the supervisor process lifetime, so policy reloads do not reset its capacity. -Active middleware sessions additionally reserve shared middleware capacity before buffering WebSocket text, and HTTP middleware reserves the same capacity before buffering request bodies; at most 32 evaluations run and 64 additional unbuffered callers wait for capacity. When both middleware bounds are full, OpenShell sheds an HTTP request with `503 Service Unavailable` before reading its body. Persistent middleware streams use a separate process-wide budget of 32 sessions. WebSocket session admission does not wait: if the budget is full, OpenShell applies each selected config's `on_error` behavior before opening a stream. +Active middleware sessions additionally reserve shared middleware capacity before buffering WebSocket text or HTTP request and response bodies; at most 32 evaluations run and 64 additional unbuffered callers wait for capacity. When both middleware bounds are full, OpenShell sheds an HTTP request with `503 Service Unavailable` before reading its body. Persistent middleware streams use a separate process-wide budget of 32 sessions. WebSocket and HTTP response stream admission does not wait: if the budget is full, OpenShell applies each selected config's `on_error` behavior before opening a stream. Because each transformed body is re-checked before the next stage runs, a middleware hook always receives a request that satisfies the sandbox policy. A stage whose output the policy rejects stops the chain; under `enforcement: audit` the rejection is logged and the request proceeds. @@ -41,6 +54,8 @@ If post-transformation policy evaluation itself fails, OpenShell denies the requ Middleware receives the request before credential injection. Operator-run services cannot inspect OpenShell-managed credentials. Middleware-visible request headers are delivered in wire order and repeated header names are preserved as separate entries. OpenShell filters credential, routing, framing, and hop-by-hop headers before invoking middleware. It rejects malformed request headers and unsupported transfer-coding sequences before middleware or policy dispatch. Headers named by a request's `Connection` field are omitted from middleware input and removed before forwarding, except for the validated WebSocket upgrade pair. +Response middleware receives the final status and safe end-to-end response headers. OpenShell omits framing, hop-by-hop, and `Connection`-nominated fields. It does not expose upstream transfer coding, transfer chunks, or socket-read boundaries through the response contract. + The request context identifies the originating sandbox to operator-run services. It carries the sandbox ID (`sandbox_id`), the sandbox name (`sandbox_name`), and the workspace (`workspace`), letting audit and approval interfaces show a human-readable name and its workspace instead of an opaque ID. `sandbox_name` and `workspace` are for display and logging only: names are workspace-scoped and may be reused for different sandbox instances, so services must use `sandbox_id` for authorization, persistence, durable correlation, and identity. `sandbox_id` is always present on middleware requests. `sandbox_name` and `workspace` are best-effort: a supervisor that cannot resolve a value, or an older supervisor that predates a field, sends an empty string. Services should fall back to the sandbox ID when the name or workspace is empty. ## Choose a Middleware Type @@ -52,7 +67,9 @@ The request context identifies the originating sandbox to operator-run services. `openshell/regex` is an example built-in middleware. It replaces only simple, self-contained token patterns in UTF-8 HTTP bodies and client WebSocket text messages; the initial pattern recognizes `sk-` tokens. It does not infer values from keyword assignments such as JSON `password` fields. This best-effort text transformation is not parser-aware and does not guarantee that it will detect or fully remove sensitive values. Its `config` accepts one field, `mode: redact`, which is also the default when the field is omitted. Unknown config fields and non-string values are rejected at policy validation. Custom expressions are not configurable yet. -Operator-run services expose bindings for supported operation and phase pairs. A binding is identified by its operation and phase. V1 supports `HttpRequest/pre_credentials` and `WebSocketMessage/pre_credentials`; a service may expose either or both. Policies attach the complete middleware by its operator-owned gateway registration name. +Operator-run services expose bindings for supported operation and phase pairs. A binding is identified by its operation and phase. V1 supports `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`; a service may expose any combination. Policies attach the complete middleware by its operator-owned gateway registration name. + +The [HTTP response transform example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-response-transform) implements the raw response gRPC contract and demonstrates all three response body modes, framing repair, and body-derived trailers. ## Register a Middleware Service @@ -78,7 +95,7 @@ timeout = "500ms" | `max_payload_bytes` | Shared operator limit applied to inspectable logical payloads across every binding exposed by the service, up to the 4 MiB platform maximum. It caps HTTP bodies and complete WebSocket text messages. | | `timeout` | Optional service-wide RPC timeout using an integer with an `ms` or `s` suffix. Defaults to `500ms`; valid values range from `10ms` through `30s`. | -Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies only to `EvaluateHttpRequest`, WebSocket preflight, and each WebSocket message. WebSocket streams have no connection-wide deadline. +Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to `EvaluateHttpRequest`, response preflight and each response event that expects a result, WebSocket preflight, and each WebSocket message. Persistent response and WebSocket streams have no connection-wide deadline. The gateway connects to every registered service and verifies its capabilities before accepting traffic. Gateway startup fails when a service is unavailable, reports an invalid capability, or exposes more than one binding for the same operation and phase. The manifest `name` is diagnostic metadata and does not need to match the operator registration name. Operator-run registration names cannot claim the reserved `openshell/` namespace. @@ -141,8 +158,8 @@ See [Policy Schema](/reference/policy-schema#network-middleware) for the complet | Value | Behavior | | --- | --- | -| `fail_closed` | Denies the HTTP request or closes the WebSocket when the stage fails. This is the default. | -| `fail_open` | Skips the failed HTTP stage. For a broken WebSocket stage stream, disables that stage for the rest of the connection and continues the remaining chain. | +| `fail_closed` | Denies an HTTP request, closes a WebSocket, returns a canonical `502 response_delivery_failed` before response commitment, or aborts response delivery after commitment. This is the default. | +| `fail_open` | Skips a failed HTTP request stage. A failed response stage is disabled and the retained input continues through later stages. A broken WebSocket stage is disabled for the rest of the connection. | Use `fail_open` only when bypassing the middleware preserves the intended security policy. OpenShell emits a detection finding when a failed stage is bypassed and a separate state-change finding when a WebSocket stage is disabled for the session. @@ -162,26 +179,30 @@ An explicit deny decision always stops the chain and denies the request or WebSo A failed `fail_closed` stage uses `error: middleware_failed` and a platform-owned `detail`. It also omits `rule_missing`, `next_steps`, and `agent_guidance`: the failure did not result from a missing network or L7 policy rule, and changing policy cannot repair it. Runtime diagnostic text is available only through sanitized operator telemetry. +A response failure has different delivery semantics because the upstream request may already have completed. Before OpenShell commits the response, a failed `fail_closed` stage returns the canonical `502 Bad Gateway` body with `error: response_delivery_failed`; a `HEAD` response carries the same headers and no body. After commitment, OpenShell aborts delivery and never appends a replacement error body. Retrying may repeat upstream side effects. + Middleware decisions are enforced regardless of the endpoint's `enforcement` mode. `enforcement: audit` applies to an endpoint's network and L7 policy rules and does not bypass middleware: a middleware deny, or a failed `fail_closed` stage, blocks the request even on an audit endpoint. A middleware service that needs to observe traffic without blocking should return an allow decision with findings, which OpenShell emits as detection findings. ## Set Payload Limits -Every middleware binding declares the largest logical payload or replacement it supports through `max_payload_bytes`. For `HTTP_REQUEST`, that payload is one request body. For `WEBSOCKET_MESSAGE`, it is one complete message rather than the whole session. +Every middleware binding declares the largest logical payload or replacement it supports through `max_payload_bytes`. For `HTTP_REQUEST`, that payload is one request body. For `HTTP_RESPONSE`, it limits a complete body selected with `WHOLE_BODY_BYTES`, each replacement, and the maximum unit a streaming stage accepts. The platform further limits response stream units to 64 KiB. For `WEBSOCKET_MESSAGE`, it is one complete message rather than the whole session. - Built-in middleware uses its OpenShell-defined limit. - Each operator-run registration sets one `max_payload_bytes` ceiling no higher than any binding's advertised `max_payload_bytes` capability. - A selected chain buffers using its largest stage limit, so every stage that can process the body receives it. -- The same per-stage limit applies to request bodies and replacement bodies. +- The same per-stage limit applies to request bodies, response whole bodies, stream inputs, and replacements. The gateway rejects a registration whose operator limit exceeds the service capability or the 4 MiB platform maximum instead of silently clamping it. OpenShell also bounds the non-payload protobuf components: 64 KiB for service config, 4 KiB for request context, 32 KiB for the target, and 128 request header lines totaling at most 64 KiB encoded. Results allow a 4 KiB discarded free-form reason, a 64-byte validated reason code, 64 header mutations totaling at most 64 KiB encoded, 32 findings of at most 4 KiB encoded each, and 64 metadata entries totaling at most 32 KiB. Middleware gRPC servers should configure request and response message limits to at least 4 MiB plus 293 KiB so every platform-valid envelope fits. At request time, exceeding a selected stage's limit is a middleware failure for that stage alone and follows that config's `on_error` behavior; other stages in the chain still run against their own limits. OpenShell can apply `fail_open` to an oversized `Content-Length` before consuming body bytes. A chunked body can cross the limit only after bytes have been consumed, so OpenShell denies that request because it cannot safely resume the original stream. +At response time, OpenShell retains V1 input until the corresponding stage result is valid, so an oversized or failed `fail_open` stage can be bypassed without losing bytes. Whole-body inspection delays response commitment. Headers-only and streaming inspection commit streaming-compatible framing after preflight. + For a WebSocket binding, `max_payload_bytes` covers complete client text messages and replacements. Exceeding a selected stage's effective text-message limit follows that stage's `on_error`. The 4 MiB parsed-text platform cap and other protocol-safety limits are independent of middleware failure policy. Binary messages are not delivered to middleware, so the operator ceiling does not become a binary relay limit; individual raw binary frames retain the 16 MiB relay-safety bound. Oversized parsed text closes the connection with code `1009`; invalid UTF-8 uses `1007`; protocol errors use `1002`; middleware or policy denials use `1008`; and policy reload uses `1012`. -## Mutate Request Headers +## Mutate HTTP Headers -A middleware result can return ordered header mutations before OpenShell injects credentials. A `write` mutation adds a value when the case-insensitive header name is absent and selects one behavior when it is already present: +A request result or response preflight can return ordered header mutations. A `write` mutation adds a value when the case-insensitive header name is absent and selects one behavior when it is already present: - `append` adds another field value. - `overwrite` removes every existing value before adding the new value. @@ -189,7 +210,9 @@ A middleware result can return ordered header mutations before OpenShell injects A `remove` mutation removes every value for a case-insensitive header name. OpenShell applies each successful stage's mutations before invoking the next middleware, so later stages observe the accumulated header state. -Writes and removals may target middleware-visible end-to-end request headers without a required prefix. Protected credential, routing, framing, and hop-by-hop headers are always rejected. Header values must not contain control characters. +Writes and removals may target middleware-visible end-to-end fields without a required prefix. One shared validator and atomic applicator enforce syntax, value, count, and size rules, then select request-, response-, or trailer-specific protected fields. Request credentials and routing fields remain protected. Response status, framing, connection control, authentication challenges, content coding, range metadata, and security policy fields remain protected. Hop-by-hop and `Connection`-nominated fields are protected in every profile. Header values must not contain control characters. + +Response trailer mutations use the same validator. Middleware may mutate an existing safe trailer, but it may add a new trailer only when that name was declared during response preflight. OpenShell validates and applies each stage's mutations atomically. An invalid operation discards every mutation from that stage and follows its `on_error` behavior. Built-in failures can name the offending header. Operator-run failures use a platform-owned error code so request-derived header text cannot reach logs or denied responses. @@ -217,15 +240,18 @@ Middleware activity is emitted through OpenShell's OCSF logging: - A binary message encountered by an active WebSocket stage emits an informational `unsupported_message_type` coverage event with message type, sequence, and byte count. It is not reported as an invocation or failure. - Built-in findings include their type, label, and aggregate count. Operator-run findings use the operator-owned registration name and a platform label plus the aggregate count; OpenShell does not log service-provided finding text or diagnostic metadata. A stage can return at most 32 findings. Exceeding the per-stage cap is an invalid response handled through `on_error`. A maximum 10-stage chain retains and emits up to 320 findings without silently dropping findings from later stages. - Registry reload success and failure are emitted as configuration state changes. +- Response coverage records status and bounded byte counts but omits response content, protected headers, and free-form middleware reasons. See [Logging](/observability/logging) for log access and [OCSF JSON Export](/observability/ocsf-json-export) for structured export. ## Current Limitations - Middleware applies only through operation bindings advertised by each implementation. For protocols that have no supported middleware operation at all, such as HTTP/2 prior knowledge or non-HTTP TCP, the existing uninspectable-traffic gate denies a host match containing `fail_closed` and relays an all-`fail_open` match with a detection finding. -- The typed operation and phase pairs are `HTTP_REQUEST/PRE_CREDENTIALS` and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. +- The typed operation and phase pairs are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. - A host match does not imply every advertised operation: an HTTP-only attachment can inspect the upgrade GET, then post-upgrade traffic passes with `binding_not_selected` coverage. - The V1 WebSocket binding inspects complete client text messages only. Binary messages pass with `unsupported_message_type` coverage for active stages; control frames and upstream-to-client messages remain outside the middleware operation. +- The V1 response binding handles HTTP/1.x representation bytes only. It bypasses `101` upgrades and does not decode content encodings or semantically parse SSE. +- V1 exposes generated protobuf and gRPC types for middleware authors. A higher-level middleware-author SDK or callback adapter is follow-up work. - Selection uses destination host include and exclude patterns. - A fail-closed middleware cannot cover `tls: skip` endpoints because OpenShell cannot inspect that traffic. An all-`fail_open` match may cover the endpoint; OpenShell bypasses the middleware and emits a detection finding. - Operator-run services use TLS `https://` when gateway JWT signing is enabled, unless the registration sets `allow_insecure_transport`. Certificates must chain to the configured custom CA or platform roots, and the endpoint hostname must match. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 22b64e9e97..eea84d0e90 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -297,13 +297,13 @@ 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` and `WebSocketMessage/pre_credentials`, so a service can inspect HTTP, WebSocket, or both. 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 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 `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, so one service can participate in any combination of those chains. Response-capable services implement the separate bidirectional `HttpResponsePreReturn.Evaluate` RPC. 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. 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. -`max_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and replacement bodies as well as complete WebSocket text messages and replacements. 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 every binding exposed by the service. It caps HTTP request and response whole bodies, response stream inputs and replacements, and complete WebSocket text messages and replacements. Response stream units have an additional 64 KiB platform cap. 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 only to `EvaluateHttpRequest`, WebSocket preflight, and each WebSocket message. An accepted WebSocket stream has no connection-wide RPC deadline. +`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 `EvaluateHttpRequest`, response preflight and each response event that expects a result, WebSocket preflight, and each WebSocket message. Accepted response and WebSocket streams have no connection-wide RPC deadline. The service `grpc_endpoint` supports plaintext `http://` and TLS `https://`. HTTPS uses the platform trust store unless `tls_ca_cert_path` names a certificate-only PEM bundle. OpenShell rejects bundles containing private keys, loads the certificates at gateway startup, and distributes only public certificates to sandbox supervisors; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:middleware:`. After authenticated `Describe` succeeds, OpenShell treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 64942a384b..adee2b5413 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -530,7 +530,7 @@ Identifies an executable that is permitted to use the associated endpoints. **Category:** Dynamic -A map of up to 10 middleware configs selected after network and L7 policy admit an HTTP request or WebSocket upgrade. Each map key is the stable policy-local config identity. Middleware selection is independent of the network policy entry that admitted the traffic. Every matching config runs once by ascending `order` before provider credential injection. WebSocket-capable bindings continue on client text messages after the upgrade. Order values must be unique across the policy, and runtime selection also enforces the 10-stage maximum. +A map of up to 10 middleware configs selected after network and L7 policy admit an HTTP request or WebSocket upgrade. Each map key is the stable policy-local config identity. Middleware selection is independent of the network policy entry that admitted the traffic. Every matching config runs once by ascending `order` for each advertised operation: request and client WebSocket bindings run before provider credential injection, while response bindings run before the final HTTP response reaches the sandbox. Order values must be unique across the policy, and runtime selection also enforces the 10-stage maximum. ```yaml showLineNumbers={false} network_middlewares: @@ -552,10 +552,10 @@ network_middlewares: | `middleware` | string | Yes | Built-in middleware name or operator-owned registration name. `openshell/` is reserved for built-ins. | | `order` | integer | No | Execution priority. Lower values run first, and values must be unique across the policy. Defaults to `0`; therefore, policies with multiple configs normally specify it explicitly. | | `config` | object | No | Implementation-owned configuration validated by the selected middleware. | -| `on_error` | string | No | Applies only after an advertised operation binding is selected. `fail_closed` denies the HTTP request or closes the WebSocket when that stage fails; `fail_open` skips a failed HTTP stage or disables a broken WebSocket stage for the rest of that connection. Defaults to `fail_closed`. | +| `on_error` | string | No | Applies only after an advertised operation binding is selected. `fail_closed` denies the request, closes the WebSocket, or fails response delivery; `fail_open` skips or disables the failed stage while retained input continues. Defaults to `fail_closed`. | | `endpoints` | object | Yes | Host selector with required non-empty `include` and optional `exclude` lists, limited to 32 combined patterns. Exclusions take precedence. | -Host selectors use the same case-insensitive exact and DNS glob semantics as network endpoints: `*` matches exactly one DNS label and `**` matches one or more labels, so `**.example.com` covers subdomains but not `example.com` itself. Brace alternates are rejected at validation. A matching attachment joins only the operation chains its implementation advertises. An HTTP-only attachment may inspect a WebSocket upgrade GET without joining the post-upgrade chain; OpenShell permits the messages and records `binding_not_selected` coverage regardless of `on_error`. WebSocket bindings inspect complete client text messages. Binary messages pass with `unsupported_message_type` coverage for active stages. A fail-closed selector that can cover a `tls: skip` endpoint is rejected because OpenShell cannot inspect that traffic through any operation. An all-`fail_open` match may cover the endpoint; the supervisor bypasses the middleware and emits a detection finding. +Host selectors use the same case-insensitive exact and DNS glob semantics as network endpoints: `*` matches exactly one DNS label and `**` matches one or more labels, so `**.example.com` covers subdomains but not `example.com` itself. Brace alternates are rejected at validation. A matching attachment joins only the operation chains its implementation advertises. An HTTP-only attachment may inspect a WebSocket upgrade GET without joining the post-upgrade chain; OpenShell permits the messages and records `binding_not_selected` coverage regardless of `on_error`. Response bindings inspect final non-`1xx` HTTP responses but bypass `101` upgrades. WebSocket bindings inspect complete client text messages. Binary messages pass with `unsupported_message_type` coverage for active stages. A fail-closed selector that can cover a `tls: skip` endpoint is rejected because OpenShell cannot inspect that traffic through any operation. An all-`fail_open` match may cover the endpoint; the supervisor bypasses the middleware and emits a detection finding. See [Supervisor Middleware](/extensibility/supervisor-middleware) for registration, failure behavior, body limits, and operational guidance. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 16202751f8..820f05cc76 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -70,11 +70,11 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | | `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` passes through the network supervisor, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints with `protocol: tcp` allow ordinary DNS resolution and native TCP connections without inspecting payloads. Endpoints without `protocol` retain L4 passthrough through an explicit proxy.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | -| `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | +| `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit traffic, OpenShell matches each config's host selectors independently. Matching entries run by unique ascending `order` for every operation and phase advertised by their implementation: before credential injection for request and client WebSocket hooks, and before sandbox delivery for HTTP response hooks. | ## Supervisor Middleware -Supervisor middleware can inspect, deny, or replace admitted HTTP request bodies and client WebSocket text messages before provider credentials are injected. Middleware selection is independent of the `network_policies` rule that admitted the traffic: each keyed `network_middlewares` entry matches the destination host through `endpoints.include` and `endpoints.exclude`. +Supervisor middleware can inspect, deny, or replace admitted HTTP request bodies and client WebSocket text messages before provider credentials are injected. It can also inspect or transform final HTTP responses before the sandbox receives them. Middleware selection is independent of the `network_policies` rule that admitted the traffic: each keyed `network_middlewares` entry matches the destination host through `endpoints.include` and `endpoints.exclude`. ```yaml network_middlewares: @@ -94,7 +94,7 @@ Matching entries run once each by ascending `order`; lower values run first, and `openshell/regex` is an example built into the supervisor. It applies fixed regular expressions to UTF-8 HTTP request bodies and complete client-to-upstream WebSocket text messages before credential injection. The initial pattern recognizes `sk-` tokens. This is a best-effort text transformation without guarantees that sensitive values will be detected or fully removed; it does not inspect binary or upstream-to-client WebSocket messages. Custom expressions are not configurable yet. Operator-run middleware must be registered by name before a policy can reference it. The gateway validates implementation-owned config before accepting the policy. -`on_error` defaults to `fail_closed`. Use `fail_open` only when skipping a selected stage that fails is acceptable. For WebSocket streams, a broken fail-open stage is disabled for the rest of that connection and OpenShell emits a state-change finding. A host-matched attachment joins only operation chains advertised by its implementation. An HTTP-only attachment may inspect the WebSocket upgrade GET, but post-upgrade messages pass with informational `binding_not_selected` coverage under either error mode. Binary messages also pass without middleware inspection and produce `unsupported_message_type` coverage for active WebSocket stages. Upstream-to-client messages remain uninspected. Policy validation rejects a fail-closed selector that can cover a `tls: skip` endpoint. An all-`fail_open` match may cover the endpoint; the supervisor bypasses the middleware and emits a detection finding. +`on_error` defaults to `fail_closed`. Use `fail_open` only when skipping a selected stage that fails is acceptable. A response failure before commitment returns `502 response_delivery_failed` when it fails closed; after commitment, OpenShell aborts the response. A fail-open response stage is disabled and the retained input continues through later stages. For WebSocket streams, a broken fail-open stage is disabled for the rest of that connection and OpenShell emits a state-change finding. A host-matched attachment joins only operation chains advertised by its implementation. An HTTP-only attachment may inspect the WebSocket upgrade GET, but post-upgrade messages pass with informational `binding_not_selected` coverage under either error mode. Binary messages also pass without middleware inspection and produce `unsupported_message_type` coverage for active WebSocket stages. Upstream-to-client WebSocket messages remain uninspected. Policy validation rejects a fail-closed selector that can cover a `tls: skip` endpoint. An all-`fail_open` match may cover the endpoint; the supervisor bypasses the middleware and emits a detection finding. See [Supervisor Middleware](/extensibility/supervisor-middleware) for registration, chain ordering, body limits, failure behavior, and operations. diff --git a/rfc/0009-supervisor-middleware/README.md b/rfc/0009-supervisor-middleware/README.md index 8c311431cc..eb7e5bdf18 100644 --- a/rfc/0009-supervisor-middleware/README.md +++ b/rfc/0009-supervisor-middleware/README.md @@ -8,6 +8,7 @@ links: - https://github.com/NVIDIA/OpenShell/issues/1734 - https://github.com/NVIDIA/OpenShell/issues/1919 - https://github.com/NVIDIA/OpenShell/issues/2010 + - https://github.com/NVIDIA/OpenShell/issues/2691 - https://github.com/NVIDIA/OpenShell/pull/2027 --- @@ -19,10 +20,11 @@ links: |------|------------|--------| | 2026-07-17 | [#2010](https://github.com/NVIDIA/OpenShell/issues/2010) | Added unary HTTP request middleware with built-in and operator-run services. | | 2026-07-28 | [#2428](https://github.com/NVIDIA/OpenShell/issues/2428) | Added WebSocket preflight and text-message evaluation, and aligned the middleware API names, limits, and diagnostics. | +| 2026-08-31 | [#2691](https://github.com/NVIDIA/OpenShell/issues/2691) | Added HTTP response pre-return inspection, normalized byte streaming, trailers, and delivery-failure semantics. | ## Summary -This RFC proposes the introduction of supervisor middleware: a supervisor-side extension system for hooks that can inspect, transform, block, and annotate supervisor-managed operations at specific operation phases. The first hook family is supervisor egress middleware for outbound sandbox HTTP requests, but the framework is intentionally named and shaped so later supervisor hooks can cover other protocols or supervisor operations without renaming the feature. +This RFC proposes supervisor middleware: a supervisor-side extension system for hooks that inspect, transform, block, and annotate supervisor-managed operations at specific operation phases. The first hook family covers outbound sandbox HTTP requests, final HTTP responses, and client WebSocket messages. The framework is named and shaped so later supervisor hooks can cover other protocols or operations without renaming the feature. ## Motivation @@ -30,7 +32,7 @@ OpenShell already controls *where* a sandbox can connect. The supervisor enforce Users have a need to control the content that leaves the sandbox. Agents routinely send prompts, tool arguments, uploaded files, which may contain sensitive information. Acting on that traffic, requires inspecting the request itself (e.g. redacting PII or secrets before they leave the sandbox, blocking requests that carry confidential documents, requiring sensitive content to be processed by a local model). -This RFC introduces supervisor middleware and its first hook family, supervisor egress middleware: hooks that run within the supervisor proxy flow and can inspect, transform, block, and annotate outbound requests based on their content. Rather than building a fixed set of content checks into OpenShell, the middleware contract lets operators process selected requests through trusted services that implement their own logic. OpenShell cannot embed every useful detection and transformation approach. We want to allow dedicated PII tools such as Presidio or NeMo Anonymizer, organization-specific classifiers, and experimental research scanners to be plugged in. A stable contract lets teams and researchers iterate on different implementations without changing OpenShell itself. +This RFC introduces supervisor middleware and its first hook family, supervisor egress middleware: hooks that run within the supervisor proxy flow and can inspect, transform, block, and annotate selected HTTP exchanges and WebSocket messages. Rather than building a fixed set of content checks into OpenShell, the middleware contract lets operators process selected traffic through trusted services that implement their own logic. OpenShell cannot embed every useful detection and transformation approach. We want to allow dedicated PII tools such as Presidio or NeMo Anonymizer, organization-specific classifiers, and experimental research scanners to be plugged in. A stable contract lets teams and researchers iterate on different implementations without changing OpenShell itself. OpenShell may still ship first-party middleware for a small number of operations where it makes sense. First-party middleware uses the same request-processing model where possible, but restricted hooks may expose supervisor-only host capabilities that external middleware can never receive. @@ -45,7 +47,7 @@ Beyond redaction, middleware also produces structured findings and string metada ## Non-goals - **Model routing.** This RFC defines the v1 string metadata that middleware can emit, but not the component that consumes findings or metadata to pick a model. Routing a request to a different model based on findings is a separate concern tracked in [#1734](https://github.com/NVIDIA/OpenShell/issues/1734). Here we only avoid blocking a future routing contract: v1 middleware decisions stay `allow`/`deny`, and any later route-selection hook should be limited to OpenShell-managed routes rather than arbitrary rewrites from one external endpoint to another. -- **A general-purpose OpenShell plugin framework.** The first version targets supervisor-owned request processing, beginning with outbound HTTP requests in the supervisor proxy flow. It is not an arbitrary plugin system for every extension point in OpenShell, and it does not cover gateway control-plane hooks, compute driver behavior, or non-supervisor extension points. +- **A general-purpose OpenShell plugin framework.** The first version targets supervisor-owned HTTP exchange and WebSocket processing in the supervisor proxy flow. It is not an arbitrary plugin system for every extension point in OpenShell, and it does not cover gateway control-plane hooks, compute driver behavior, or non-supervisor extension points. - **Constraining or sandboxing the middleware itself.** A middleware gets raw access to request content. OpenShell routes payloads to a service the operator chose to trust; it does not sandbox the middleware, verify its behavior, or prevent a malicious one from mishandling the data it inspects. Authenticated encrypted transport protects the connection but does not make the service trustworthy. Stronger service isolation, such as running middleware in its own sandbox, remains follow-up work. - **Runtime management of middleware.** Middleware is declared in gateway configuration. A runtime CLI or API to add, list, or validate middleware - and ergonomic tooling to make registration easy, such as a dedicated command or an agent skill that scaffolds and registers a new service - is deferred to follow-up work once the contract stabilizes. - **Guaranteeing detection correctness.** OpenShell places the hook and enforces the decision the middleware returns, but it does not guarantee that a middleware actually catches all sensitive content. Detection quality is the middleware's responsibility. @@ -57,17 +59,17 @@ This RFC uses the following terms with specific meanings. - **Egress.** An outbound request a sandbox sends to an upstream destination through the supervisor proxy. The v1 middleware hook acts on the parsed request the supervisor has already admitted and is about to forward, not on raw packets or arbitrary network activity. - **Middleware.** A service that inspects, transforms, blocks, or annotates supervisor operations through the contract defined in this RFC. In the v1 egress hook, a middleware owns its detection and transformation logic and never makes the upstream call itself; the supervisor always owns the upstream call. -- **Registered middleware.** An external middleware service an operator declares in gateway configuration as a diagnostic name plus a gRPC endpoint. Registration is an administrative action that establishes which endpoints may receive raw request content. The service exposes stable binding IDs through `Describe`, and policy refers to those binding IDs rather than to the registration name. -- **Built-in middleware.** A middleware that ships inside the supervisor binary and runs in-process, with no network hop and no gateway registration. Built-in binding IDs use the reserved `openshell/` namespace, for example `openshell/regex`. -- **Operation.** The typed method plus typed phase that identifies the point where OpenShell invokes middleware. This RFC's v1 middleware evaluates `method=HTTP_REQUEST, phase=PRE_CREDENTIALS`. -- **Hook.** A named middleware API contract for one operation. Middleware hook names are part of the middleware API, not arbitrary strings supplied by the caller. The v1 hook is `HTTP_REQUEST/PRE_CREDENTIALS`, which runs in the HTTP relay once the request is parsed and admitted by policy and before credential injection. The design allows more typed operations later without changing the v1 hook's request shape. +- **Registered middleware.** An external middleware service an operator declares in gateway configuration with an operator-owned name and gRPC endpoint. Registration establishes which endpoints may receive raw request or response content. Policy refers to the registration name and attaches every operation and phase the service advertises through `Describe`. +- **Built-in middleware.** A middleware that ships inside the supervisor binary and runs in-process, with no network hop and no gateway registration. Built-in names use the reserved `openshell/` namespace, for example `openshell/regex`. +- **Operation.** The typed method plus typed phase that identifies the point where OpenShell invokes middleware. V1 evaluates `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. +- **Hook.** A named middleware API contract for one operation. Middleware hook names are part of the middleware API, not arbitrary strings supplied by the caller. Request and client WebSocket hooks run before credential injection. The response hook runs after the final upstream response head and before sandbox delivery. - **Evaluation.** One invocation of middleware for a specific operation, request context, bounded body, and middleware config. Middleware keeps operation-specific methods such as `EvaluateHttpRequest` because inputs and outputs differ by protocol or operation type. -- **Result.** The response to an evaluation. For the v1 HTTP request hook, the result carries an allow/deny decision, optional replacement content and safe header mutations, findings, metadata, and safe error information. -- **Middleware config.** A policy entry stored under a stable policy-local map key that namespaces metadata and diagnostics. The optional `name` field is a human-readable label and defaults to the map key. The `middleware` field binds the entry to a service-owned binding ID, while the remaining fields define service-specific configuration, endpoint selectors, failure behavior, and ordering. +- **Result.** The response to an evaluation. HTTP request and WebSocket results can allow or deny. Response preflight selects whether and how to inspect, while each body result acknowledges one input with pass-through or transformation. Results may also carry safe mutations, findings, metadata, and diagnostics. +- **Middleware config.** A policy entry stored under a stable policy-local map key that namespaces metadata and diagnostics. The optional `name` field is a human-readable label and defaults to the map key. The `middleware` field selects a built-in or operator-owned registration name, while the remaining fields define service-specific configuration, endpoint selectors, failure behavior, and ordering. - **Manifest.** The self-description a middleware returns from `Describe`: its service version and service-owned bindings for the hooks it supports. The protobuf package `openshell.middleware.v1` defines the wire-version boundary; requests and manifests do not carry a duplicate API-version string. - **Decision.** The allow-or-deny outcome a middleware returns for a request. `allow` lets the request proceed (possibly transformed); `deny` short-circuits it. This vocabulary matches the rest of the OpenShell policy system. - **Failure policy.** The configured `on_error` behavior when middleware cannot return a valid result: `fail_closed` denies the request, while `fail_open` lets it continue without that middleware's transformation while recording an enforcement failure. `fail_closed` is the default whenever processing is required. -- **Transformation.** A middleware returning replacement content, and any allowed header mutations, that the supervisor forwards in place of the original request. A later middleware in a chain sees the previous stage's transformed content. +- **Transformation.** A middleware returning replacement content and allowed header mutations that the supervisor forwards in place of the original representation. A later middleware in the same operation chain sees the previous stage's transformed content. - **Finding.** A structured, audit-safe observation a middleware reports about a request, such as a machine-readable type, safe label, count, confidence, and optional severity. A finding never carries raw matched values, redacted spans, or the original sensitive content. The supervisor maps findings into OCSF `DetectionFinding` events. - **Metadata.** Namespaced string key/value annotations a middleware emits into a request-local bag. V1 metadata never carries raw sensitive values. Routing-grade typed metadata, including usage markers such as audit-safe, routing-safe, or internal-only, is deferred until a component consumes it. - **Chain.** The ordered set of middleware configs that applies to a single request. Each config runs in turn, a later stage sees the previous stage's transformed content, a `deny` short-circuits the remaining stages, and each matching config runs at most once per request. @@ -76,15 +78,15 @@ This RFC uses the following terms with specific meanings. The first version makes supervisor middleware concrete through one egress hook family without prematurely standardizing every future deployment model. It supports first-party built-ins that run inside the supervisor and external services that the operator runs and statically registers. OpenShell routes selected egress through the resulting chain, and each stage returns a decision plus optional transformed content, findings, and metadata. This keeps the first iteration focused on the contract, failure behavior, and sandbox integration while leaving other deployment shapes open (see [appendices/deployment-options.md](appendices/deployment-options.md)). -External middleware services are exposed over gRPC network endpoints. The stable contract requires authenticated encrypted transport; phase 1 alone may explicitly opt into plaintext for trusted local or isolated research environments, and phase 2 removes that exception. See [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication). +External middleware services are exposed over gRPC network endpoints. The stable contract requires authenticated encrypted transport. Operators may explicitly opt a registration into unauthenticated plaintext only for trusted local or isolated research environments. See [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication). ### Architecture Three components participate: -- **Gateway (control plane).** Registers middleware, validates that each registered service supports the policies that reference it, and distributes the effective middleware configuration to supervisors. The gateway never sees live request bodies; it stays off the hot path. -- **Supervisor proxy (data plane).** Calls the middleware on the request hot path, enforces the returned decision, forwards only the content the middleware returns, and carries emitted metadata forward. The supervisor owns the upstream call. -- **Middleware implementation.** Inspects the request and returns a decision, optional transformed content, findings, and metadata. A middleware can be a first-party built-in installed in-process or an operator-run service reached over gRPC. Both use the same chain and result semantics and never make the upstream call. Restricted built-ins may access supervisor-only capabilities unavailable to external services. +- **Gateway (control plane).** Registers middleware, validates that each registered service supports the policies that reference it, and distributes the effective middleware configuration to supervisors. The gateway never sees live request or response bodies; it stays off the hot path. +- **Supervisor proxy (data plane).** Calls middleware on request, response, and WebSocket hot paths, enforces valid results and configured failure behavior, and owns the upstream call and downstream framing. +- **Middleware implementation.** Inspects one operation-specific representation and returns a decision or acknowledgement, optional transformed content, findings, and metadata. A middleware can be a first-party built-in installed in-process or an operator-run service reached over gRPC. Restricted built-ins may access supervisor-only capabilities unavailable to external services. ```mermaid graph LR @@ -108,11 +110,13 @@ graph LR SUP -->|"request content + context"| MW MW -->|"decision + transformed
content + metadata"| SUP SUP -->|"forwards allowed request"| UP + UP -->|"upstream response"| SUP + SUP -->|"inspected response"| AGENT ``` ### Operation phases and placement -A middleware service provides hook implementations that the supervisor invokes at defined operation phases in the proxy flow. This version defines a single typed middleware operation, `HTTP_REQUEST/PRE_CREDENTIALS`, and is structured so more operations can be added later. The supervisor invokes the hook in the HTTP relay once the request has been parsed and admitted by policy, and before OpenShell injects upstream credentials. +A middleware service provides hook implementations that the supervisor invokes at defined operation phases in the proxy flow. V1 defines `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. The HTTP request hook runs after policy admission and before credential injection. The HTTP response hook runs after the upstream returns the final non-`1xx` head and before OpenShell commits it to the sandbox. ```mermaid graph LR @@ -127,6 +131,9 @@ graph LR RECHECK -->|"next stage or complete"| ROUTE["Route selection"] ROUTE --> CRED["Credential injection"] CRED --> UP["Upstream forwarding"] + UP --> FINAL["Final response head"] + FINAL --> RETURN["HTTP_RESPONSE / PRE_RETURN
(middleware stage)"] + RETURN --> DELIVER["Repair framing and
deliver to sandbox"] ``` This ordering is deliberate: @@ -138,20 +145,23 @@ This ordering is deliberate: - A middleware's explicit denial and `fail_closed` behavior are enforcement decisions in their own right and remain blocking even when the network endpoint uses `enforcement: audit`. - Route selection - choosing which upstream, and in future which model, serves the request - runs after the hook, so the later model-router work has a clear handoff point for any middleware findings or metadata it chooses to consume. There is no model router in v1; this box marks where one would plug in. Middleware does not forward traffic itself, and v1 deliberately has no `forward_to` decision. Any later route-selection phase should return an OpenShell-owned route decision for managed destinations, not an arbitrary rewrite from one external endpoint to another. - The upstream call stays owned by the supervisor, never the middleware. +- Interim `1xx` responses pass through unchanged. A `101` upgrade bypasses generic response-body processing. +- Response preflight may skip or inspect headers only, a bounded complete body, or normalized lockstep byte units. V1 keeps every input until its acknowledgement, which preserves fail-open recovery. Fail-closed returns a canonical 502 before commitment and aborts after commitment. The hook operates on a parsed HTTP request, so it runs wherever OpenShell can parse one. The supervisor proxy TLS-terminates and HTTP-parses every egress connection that is not marked `tls: skip` and is not opaque, non-HTTP traffic, so the hook fires on those requests regardless of whether the endpoint also declares a `protocol`. Declaring a `protocol` additionally subjects the request to L7 Rego policy; an endpoint without one is still terminated and parsed and the middleware hook runs on it. The only traffic the hook cannot inspect is traffic OpenShell never parses: `tls: skip` endpoints and opaque TCP or TLS passthrough. Policy validation rejects any middleware selector whose possible hosts overlap an endpoint configured with `tls: skip`, so selector-based middleware cannot be silently bypassed by an unparsed path. -> **Update in PR #2477 - WebSocket middleware:** The following text extends the original HTTP-only scope. It adds operation-specific selection, client WebSocket text-message inspection, and explicit coverage for traffic that an attached middleware cannot inspect. +> **Updates in PR #2477 and issue #2691:** The following text extends the original request-only scope with client WebSocket text-message inspection and HTTP response pre-return processing. If a selected operation chain becomes uninspectable at runtime, OpenShell examines that chain. If any selected stage is `fail_closed`, the request is denied. If every selected stage is `fail_open`, OpenShell relays the request and emits a bypass `DetectionFinding`. This chain-level rule prevents one permissive selected stage from overriding a required stage. -Attachment and operation selection are separate. A destination host selector attaches a policy config, then the implementation manifest decides whether that config participates in `HTTP_REQUEST/PRE_CREDENTIALS`, `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, or both. The absence of an operation binding is a declared capability boundary rather than a middleware failure, so `on_error` does not apply. OpenShell records informational coverage when an attached config does not join the WebSocket chain. +Attachment and operation selection are separate. A destination host selector attaches a policy config, then the implementation manifest decides whether that config participates in `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, or any combination. The absence of an operation binding is a declared capability boundary rather than a middleware failure, so `on_error` does not apply. OpenShell records informational coverage when an attached config does not join the WebSocket chain. WebSocket sits on this boundary. The upgrade request is a normal HTTP/1.1 request that an HTTP binding can inspect, allow, or deny. A separate V1 operation covers complete client-to-upstream text messages after upgrade. Binary messages, control frames, and upstream-to-client messages remain outside that operation. To keep the v1 boundary unambiguous: **In scope for v1:** - Inspectable HTTP/1.x requests that OpenShell terminates and parses, after L4 and SSRF admit them (and L7 policy too, where the endpoint declares a `protocol`). +- Final non-`1xx` HTTP/1.x response heads, bounded complete identity-coded bodies, normalized lockstep identity-coded byte streams, and normalized trailers before sandbox delivery. - WebSocket upgrade (handshake) requests - the HTTP request that initiates the upgrade. - Complete client-to-upstream WebSocket text messages for implementations that advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. - Bounded request bodies: a `Content-Length` or bounded chunked body OpenShell can buffer within the applicable chain cap. @@ -160,26 +170,27 @@ WebSocket sits on this boundary. The upgrade request is a normal HTTP/1.1 reques **Out of scope for v1:** - HTTP/2 and HTTP/3. The proxy's TLS termination pins ALPN to `http/1.1` today, so these are not introspected. -- Binary and control WebSocket messages, upstream-to-client WebSocket messages, and response-body scanning. +- Binary and control WebSocket messages and upstream-to-client WebSocket messages. - Opaque TCP streams and endpoints with `tls: skip`. - Unbounded streaming uploads or full-duplex request processing. -- Multipart or compressed body semantics, unless a selected service's manifest and policy explicitly support them within the size limits. +- Multipart request semantics, response content decoding, partial response transformation, and semantic SSE parsing. -The request hook is synchronous and runs once for every selected stage. Timeout, failure behavior, and body buffering are therefore load-bearing parts of the design. The supervisor buffers up to the largest resolved stage limit, bounded by the 4 MiB platform maximum. A stage whose smaller limit is exceeded applies its own `on_error`, and later stages may still run when that result is `fail_open`. If an oversized `Content-Length` is known before body consumption, the supervisor may preserve streaming and fail open only when every affected stage permits it. If a chunked body crosses the cap after bytes have been consumed, the request is denied because the raw stream can no longer be resumed safely. The hook remains before any credential rewrite, which keeps OpenShell-managed credentials away from external middleware. Other operation phases such as pre-policy classification, a credential-visible `HttpRequest/post_credentials` hook for request signing (built-in-only, for example `openshell/sigv4`), response inspection, route selection for OpenShell-managed destinations, and streaming message hooks are possible future extensions and are out of scope for v1. +Request evaluation is synchronous and runs once for every selected stage. Timeout, failure behavior, and body buffering are therefore load-bearing parts of the design. The supervisor buffers up to the largest resolved stage limit, bounded by the 4 MiB platform maximum. A stage whose smaller limit is exceeded applies its own `on_error`, and later stages may still run when that result is `fail_open`. If an oversized request `Content-Length` is known before body consumption, the supervisor may preserve streaming and fail open only when every affected stage permits it. If a chunked request crosses the cap after bytes have been consumed, the request is denied because the raw stream can no longer be resumed safely. Response streaming instead retains each V1 unit until acknowledgement, so fail-open never loses that input. Other operation phases such as pre-policy classification, a credential-visible `HttpRequest/post_credentials` hook for request signing, route selection for OpenShell-managed destinations, and upstream-to-client WebSocket inspection remain future extensions. ### The middleware contract -The contract has two parts: a configuration-time handshake and a request-time evaluation. The evaluation runs on the *hot path* - the synchronous, per-request path through the supervisor proxy, as opposed to the control-plane path used to fetch config. Middleware only sits on this path for sandboxes whose policy configures it: a sandbox with no middleware in its policy is unaffected and pays no per-request cost. Middleware is therefore an explicit opt-in, and this change is transparent to existing usage. +The contract has two parts: a configuration-time handshake and operation-specific evaluation. Evaluation runs on the data-plane hot path rather than the control-plane path used to fetch config. Middleware only sits on this path for sandboxes whose policy configures it: a sandbox with no middleware in its policy is unaffected and pays no per-request cost. Configuration-time: - `Describe` reports a service-provided diagnostic name, service version, and service-owned bindings for the typed operation and phase pairs it supports. A binding includes its maximum accepted body size and may override the service RPC timeout. - `ValidateConfig` lets the service validate its own service-specific configuration fragment. -Request-time: +Data-plane evaluation: -- `EvaluateHttpRequest` carries the selected binding ID and typed operation phase (`PRE_CREDENTIALS`) plus the request context, middleware configuration from policy, HTTP request target, repeated safe headers in wire order, and bounded body. +- `EvaluateHttpRequest` carries the `PRE_CREDENTIALS` phase, request context, middleware configuration, HTTP request target, repeated safe headers in wire order, and bounded body. - `HttpRequestResult` is a response OpenShell can apply directly: `allow` or `deny`, a reason, optional replacement content, ordered header mutations, findings, and namespaced metadata. +- `HttpResponsePreReturn.Evaluate` is a separate bidirectional stream. It carries response preflight, normalized body units, body end, trailers, and session end. Results acknowledge preflight, each body unit, and trailers. A simplified sketch of the gRPC contract: @@ -191,38 +202,48 @@ service SupervisorMiddleware { // operation=HTTP_REQUEST, phase=PRE_CREDENTIALS. rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); + + rpc EvaluateWebSocketSession(stream WebSocketSessionEvent) + returns (stream WebSocketSessionEventResult); +} + +service HttpResponsePreReturn { + rpc Evaluate(stream HttpResponseEvent) + returns (stream HttpResponseEventResult); } message MiddlewareManifest { string name = 1; // service-provided diagnostic name string service_version = 2; // service implementation version, informational repeated MiddlewareBinding bindings = 3; + string expected_audience = 4; } message MiddlewareBinding { - string id = 1; // service-owned stable ID - SupervisorMiddlewareOperation operation = 2; - SupervisorMiddlewarePhase phase = 3; - uint64 max_payload_bytes = 4; // one logical request or message payload - string timeout = 5; // optional binding-specific RPC timeout + SupervisorMiddlewareOperation operation = 1; + SupervisorMiddlewarePhase phase = 2; + uint64 max_payload_bytes = 3; // one logical input or replacement + string timeout = 4; // optional binding-specific RPC timeout } message HttpRequestEvaluation { - string binding_id = 1; // selected manifest binding - SupervisorMiddlewarePhase phase = 2; + SupervisorMiddlewarePhase phase = 1; - RequestContext context = 3; - google.protobuf.Struct config = 4; // service-specific, from policy + RequestContext context = 2; + google.protobuf.Struct config = 3; // service-specific, from policy - HttpRequestTarget target = 5; - repeated HttpHeader headers = 6; // safe subset, duplicates and wire order preserved - bytes body = 7; // bounded + HttpRequestTarget target = 4; + repeated HttpHeader headers = 5; // safe subset, duplicates and wire order preserved + bytes body = 6; // bounded + string middleware_name = 7; // built-in or operator registration name } message RequestContext { string request_id = 1; string sandbox_id = 2; Process originating_process = 3; // optional, per-connection + string sandbox_name = 4; // display and logging only + string workspace = 5; // display and logging only } message HttpRequestTarget { @@ -281,22 +302,43 @@ message HttpRequestResult { repeated HeaderMutation header_mutations = 5; repeated Finding findings = 6; map metadata = 7; + string reason_code = 8; +} + +message HttpResponseEvent { + oneof event { + HttpResponsePreflight preflight = 1; + HttpResponseBodyUnit body = 2; + HttpResponseBodyEnd body_end = 3; + HttpResponseTrailers trailers = 4; + HttpResponseSessionEnd session_end = 5; + } +} + +message HttpResponseEventResult { + oneof result { + HttpResponsePreflightDecision preflight_decision = 1; + HttpResponseBodyResult body_result = 2; + HttpResponseTrailersResult trailers_result = 3; + } } ``` The evaluation and result are shaped so middleware composes cleanly in a chain. The allow/deny decision is a first-class result field rather than being mixed into content. If `has_body` is true, the transformed content a middleware returns (`HttpRequestResult.body`) becomes the request body the next middleware receives as `HttpRequestEvaluation.body`; if `has_body` is false, the supervisor keeps the previous body. The supervisor also feeds allowed header mutations into the next stage, so a chain is effectively a fold over a single request representation; a `deny` from any stage short-circuits the rest. See [Middleware ordering](#middleware-ordering) for how chains are assembled and ordered. -Headers use a repeated representation so duplicate lines and wire order survive evaluation and chaining. Before an external call, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. A result may return ordered writes and removals for visible end-to-end fields without a required prefix. Writes support append, overwrite, and skip modes. Credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers remain protected. Header values containing control characters are invalid. OpenShell validates and applies a stage's mutations atomically. If any mutation is invalid, none are applied and the stage follows its configured `on_error` behavior. +Headers use a repeated representation so duplicate lines and wire order survive evaluation and chaining. A request result or response preflight may return ordered writes and removals for visible end-to-end fields without a required prefix. Writes support append, overwrite, and skip modes. One shared validator and atomic applicator selects request-, response-, or trailer-specific protected fields. Header values containing control characters are invalid. If any mutation is invalid, none are applied and the stage follows its configured `on_error` behavior. + +> **Updates in PR #2477 and issue #2691:** The following contract text adds bidirectional WebSocket and HTTP response RPCs without changing the unary request RPC. -> **Update in PR #2477 - WebSocket middleware:** The following contract text adds the bidirectional `EvaluateWebSocketSession` RPC, WebSocket preflight, message limits, and the WebSocket binding for the built-in regex middleware. The unary HTTP contract does not change. +The interface is gRPC. The protobuf package `openshell.middleware.v1` is the protocol version boundary, so manifests and evaluation messages do not repeat an API-version string. HTTP request evaluation remains unary. Complete client-to-upstream WebSocket text messages use `EvaluateWebSocketSession`. Final HTTP responses use the separate `HttpResponsePreReturn.Evaluate` stream so preflight can choose headers-only, bounded whole-body, or normalized lockstep byte processing per response. Streaming is not baked into `EvaluateHttpRequest`; future request streaming should add another operation-specific method rather than changing the existing method's cardinality. -The interface is gRPC. The protobuf package `openshell.middleware.v1` is the protocol version boundary, so manifests and evaluation messages do not repeat an API-version string. HTTP evaluation remains unary: the supervisor buffers the bounded body, sends one `HttpRequestEvaluation`, and receives one `HttpRequestResult`. Complete client-to-upstream WebSocket text messages use the separate bidirectional-streaming `EvaluateWebSocketSession` RPC. The supervisor sends `WebSocketSessionEvent` values; the service returns `WebSocketSessionEventResult` values for preflight and message events, while session start and end are notifications without corresponding results. Streaming is not baked into `EvaluateHttpRequest`; future chunked HTTP transport should add another operation-specific method rather than changing the existing method's cardinality. Possible extensions are collected in the [protocol-extensions appendix](appendices/protocol-extensions.md). Built-in middleware uses the same logical contracts in-process; the `openshell/regex` built-in advertises both V1 operations. +V1 applies explicit public envelope limits before invoking a service or accepting its result: 64 KiB for encoded config, 4 KiB for request context, 32 KiB for the target, 128 header lines and 64 KiB of encoded headers, 4 MiB for the logical payload, 4 KiB for a reason, 64 header mutations with at most 32 KiB of validated name/value data and 64 KiB encoded, 32 findings per stage with each finding at most 4 KiB encoded, and 64 metadata entries totaling at most 32 KiB. A chain has at most 10 stages and therefore at most 320 findings. Middleware gRPC servers configure request and response message limits to cover the 4 MiB payload plus at least 293 KiB for the remaining envelope. -V1 applies explicit public envelope limits before invoking a service or accepting its result: 64 KiB for encoded config, 4 KiB for request context, 32 KiB for the target, 128 header lines and 64 KiB of encoded headers, 4 MiB for the logical payload, 4 KiB for a reason, 64 header mutations with at most 32 KiB of validated name/value data and 64 KiB encoded, 32 findings per stage with each finding at most 4 KiB encoded, and 64 metadata entries totaling at most 32 KiB. A chain has at most 10 stages and therefore at most 320 findings. Middleware gRPC servers configure request and response message limits to cover the 4 MiB payload plus at least 292 KiB for the remaining envelope. +For WebSocket traffic, a service advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` with `max_payload_bytes`, which limits one complete message or replacement rather than the whole session. OpenShell opens one `EvaluateWebSocketSession` stream per selected stage and upgrade attempt. Future upstream-to-client WebSocket inspection reserves `WEBSOCKET_MESSAGE/PRE_RETURN`. -For WebSocket traffic, a service advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` with `max_payload_bytes`, which limits one complete message or replacement rather than the whole session. HTTP bindings use the same field for one request body or replacement. An attached service without that exact binding does not join the chain, does not apply `on_error`, and produces internal `binding_not_selected` coverage. OpenShell opens one phase-specific `EvaluateWebSocketSession` stream per selected stage and upgrade attempt. Future upstream-to-client inspection uses the same RPC with `PRE_RETURN`; a service selected for both phases receives two independent streams for the WebSocket session. A bounded preflight exposes only the admitted destination through `HttpRequestTarget`, with its path separated from query data, requested subprotocols, sandbox context, the attached API middleware name, and validated implementation config. Policy-local config identity remains internal for audit and denial metadata. OpenShell evaluates selected preflights concurrently. Each stage returns `inspect`, voluntary `skip`, or authoritative `deny` before upstream contact, plus optional bounded reason, reason code, findings, and metadata. `deny` is a successful decision enforced independently of `on_error` and takes precedence over concurrent failures; failures alone follow each stage's `on_error`. OpenShell sends the terminal reason to every still-writable stream whose preflight opened successfully, at most once per stage. Inspecting stages that continue receive `session_start`, then complete logical text messages with monotonic sequence numbers. Stages run in global policy order and each sees the prior stage's accepted replacement. Binary logical messages pass through without middleware inspection under both error modes, consume a session-global sequence, and emit `unsupported_message_type` coverage for active stages; a later text RPC may therefore contain a valid sequence gap. `PRE_RETURN` and upstream-to-client inspection are reserved for a later implementation. +For HTTP responses, a service advertises `HTTP_RESPONSE/PRE_RETURN`. Preflight sees the final status, safe headers, request target and context, configuration, and effective payload limit. It returns bounded `SKIP` diagnostics or `INSPECT` with `HEADERS_ONLY`, `WHOLE_BODY_BYTES`, or `STREAM_BYTES`. Body units use contiguous stage-local sequences starting at one and require exactly one in-order `PASS_THROUGH` or `TRANSFORM` result. OpenShell sends `body_end` without expecting a result, then sends normalized trailers to body-inspecting stages. New trailer names must be declared during preflight. V1 has no response-body denial result. -Inspectable WebSocket text input and replacements share the 4 MiB platform cap. The operator's `max_payload_bytes` is the shared HTTP-body and WebSocket-text ceiling, further constrained by each operation binding's capability. It does not bound binary pass-through, which retains a separate raw-frame safety limit. Logical messages use a protobuf `oneof` with `string text` and `bytes binary` variants; results use an optional matching replacement `oneof`, whose presence also represents an empty replacement without a separate boolean. Protobuf decoding enforces UTF-8 for text, and OpenShell rejects replacement variants that would change the message type. A complete text message holds one process-wide admission permit for its entire chain; preflight fan-out holds one permit until every stage resolves. Permit waiting is backpressure and does not consume the per-message deadline. Per-stage timeouts are also bounded by a 30-second total chain budget, which applies to HTTP chains too. This bound controls concurrency and peak buffered inspection memory; it is not rate limiting. +Inspectable HTTP request bodies, response whole bodies and stream inputs, replacements, and WebSocket text messages share the 4 MiB platform cap. Response stream units have an additional 64 KiB platform cap. The operator's `max_payload_bytes` is the shared ceiling, further constrained by each operation binding's capability. It does not bound binary WebSocket pass-through, which retains a separate raw-frame safety limit. Logical WebSocket messages use a protobuf `oneof` with `string text` and `bytes binary` variants; results use an optional matching replacement `oneof`, whose presence also represents an empty replacement without a separate boolean. Protobuf decoding enforces UTF-8 for text, and OpenShell rejects replacement variants that would change the message type. A complete text message holds one process-wide admission permit for its entire chain; preflight fan-out holds one permit until every stage resolves. Permit waiting is backpressure and does not consume the per-message deadline. Per-stage timeouts are also bounded by a 30-second total chain budget, which applies to HTTP chains too. This bound controls concurrency and peak buffered inspection memory; it is not rate limiting. The `originating_process` is the same identity OpenShell resolves on the egress path - the binary, pid, and ancestor chain it uses for binary-scoped network policy and OCSF audit. It is per-connection rather than strictly per-request and is optional. Middleware must treat missing process data as unavailable rather than as an authorization failure. The initial implementation leaves this field unset until reliable propagation is available. @@ -306,7 +348,7 @@ The `originating_process` is the same identity OpenShell resolves on the egress Shared mechanics: -- **Endpoint exposure and auth.** Both extension systems use gRPC network endpoints. Their stable transport contract requires confidentiality and service authentication. During phase 1 only, supervisor middleware may explicitly opt into plaintext for trusted local or isolated research environments. Endpoint declaration, identity binding, credential material, and rotation should use shared mechanics where practical. +- **Endpoint exposure and auth.** Both extension systems use gRPC network endpoints. Their stable transport contract requires confidentiality and service authentication. Supervisor middleware may explicitly opt a registration into unauthenticated plaintext only for trusted local or isolated research environments. Endpoint declaration, identity binding, credential material, and rotation should use shared mechanics where practical. - **Manifest description.** Both extension systems use `Describe` to return a manifest that declares a diagnostic service name, implementation version, and service-owned bindings for supported hook points. - **Operation phases.** Both systems hook into a named operation plus phase. The phase sets differ by system, but the concept is the same: `method=CreateSandbox, phase=pre_request` for a gateway interceptor, and `HTTP_REQUEST/PRE_CREDENTIALS` for v1 supervisor middleware. - **Evaluation and result.** Both systems run an evaluate-style request and return a result. Middleware keeps operation-specific methods such as `EvaluateHttpRequest` because inputs and outputs differ by protocol or operation type; interceptor methods and messages are defined by RFC 0010. @@ -324,11 +366,11 @@ Intentional differences: The middleware gRPC contract lives under a major-versioned protobuf package (`openshell.middleware.v1`), the same convention the compute-driver contract uses in [RFC 0001](../0001-core-architecture/README.md). Within a stable major version, changes stay additive and backward compatible - new fields, RPCs, operation phases, and manifest fields can be added - while breaking wire or semantic changes require a new major version. The research preview may still make intentional breaking changes before the contract is declared stable. -The protobuf package is the wire-version handshake. `Describe` reports a diagnostic service name, implementation version, and stable binding IDs for supported hook points; it does not carry a second API-version field. Manifest validation is mandatory: if OpenShell cannot fetch the manifest, bindings conflict, a service claims the reserved `openshell/` namespace, or policy asks for an unsupported binding or invalid config, the gateway rejects the relevant configuration before traffic can depend on it. Runtime invocation failures are handled through `on_error` and use `fail_closed` by default. +The protobuf package is the wire-version handshake. `Describe` reports a diagnostic service name, implementation version, and operation/phase bindings; it does not carry a second API-version field. Manifest validation is mandatory: if OpenShell cannot fetch the manifest, operation/phase bindings conflict, a registration claims the reserved `openshell/` namespace, or policy supplies invalid config, the gateway rejects the relevant configuration before traffic can depend on it. Runtime invocation failures are handled through `on_error` and use `fail_closed` by default. ### Registration and delivery -The operator registers available external middleware services in gateway configuration under `openshell.supervisor.middleware`. The namespace identifies the subsystem whose behavior is extended, not the process that reads the configuration. The gateway still loads, validates, and distributes these registrations to supervisors. Each entry has a diagnostic name, gRPC endpoint, maximum logical payload size, optional RPC timeout, and transport settings. The diagnostic name identifies the configured connection in logs but is not a policy key. Policy authors select stable binding IDs returned by `Describe`, so they cannot point traffic at an arbitrary endpoint and do not depend on an operator-local registration name. +The operator registers available external middleware services in gateway configuration under `openshell.supervisor.middleware`. The namespace identifies the subsystem whose behavior is extended, not the process that reads the configuration. The gateway loads, validates, and distributes these registrations to supervisors. Each entry has an operator-owned name, gRPC endpoint, maximum logical payload size, optional RPC timeout, and transport settings. Policy authors select the registration name; `Describe` determines which typed operation chains that attachment joins. The v1 transport is gRPC over a network endpoint reachable from every supervisor across Docker, Podman, VM, and Kubernetes drivers. In local single-player deployments, a loopback endpoint such as `127.0.0.1:1234` may be translated to `host.openshell.internal` so a supervisor can reach a service running on the local host. That loopback shorthand is not an HA deployment model: Kubernetes and other shared deployments should register a routable service DNS name or address that every supervisor can reach directly. Other deployment shapes are deferred until OpenShell has a universal way to make those endpoints reachable from the relevant supervisor environments. @@ -338,7 +380,7 @@ name = "anonymizer" grpc_endpoint = "http://127.0.0.1:1234" max_payload_bytes = 4194304 timeout = "500ms" -allow_insecure = true +allow_insecure_transport = true [[openshell.supervisor.middleware]] name = "agent-traces-exporter" @@ -346,19 +388,19 @@ grpc_endpoint = "https://middleware.example.internal:443" max_payload_bytes = 1048576 ``` -The stable transport requirement is confidentiality plus authentication of the intended middleware service. Phase 1 may temporarily accept a plaintext `http://` endpoint only when the same entry explicitly sets `allow_insecure = true`. OpenShell rejects plaintext without that opt-in, warns prominently, and records the insecure registration as auditable configuration state. This escape hatch is limited to trusted local development and isolated research environments. Phase 2 removes plaintext support and the `allow_insecure` field, requiring authenticated encrypted transport. That removal is an intentional research-preview breaking change with no long-term compatibility obligation. The exact phase 2 mechanism, such as mTLS or TLS plus explicit caller authentication, is follow-up protocol work (see [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication)). +Authenticated deployments use TLS plus short-lived exact-audience gateway-signed bearer tokens. A plaintext `http://` endpoint is accepted only when the registration explicitly sets `allow_insecure_transport = true`; OpenShell then attaches no credential and emits a startup warning. This escape hatch is limited to trusted local development and isolated research environments. See the [extension-authentication appendix](appendices/extension-authentication.md). For each binding, the operator's `max_payload_bytes` must not exceed the binding capability returned by `Describe` or the 4 MiB platform maximum. The gateway rejects an invalid registration rather than silently clamping it. The resulting operator limit applies to every binding exposed by that registration. -RPC timeouts use an integer with an `ms` or `s` suffix, range from 10 ms through 30 s, and default to 500 ms. A binding may advertise its own timeout through `Describe`; that value overrides the service registration timeout. The service timeout applies to `Describe`, while the effective binding timeout applies to `ValidateConfig` and `EvaluateHttpRequest`. +RPC timeouts use an integer with an `ms` or `s` suffix, range from 10 ms through 30 s, and default to 500 ms. A binding may advertise a shorter timeout through `Describe`, but cannot extend the service registration timeout. The service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to request evaluation, response preflight and result-bearing response events, WebSocket preflight, and each WebSocket message. Persistent streams have no whole-session deadline. The external-service endpoint is trusted operator infrastructure in v1. The auth design must make both directions explicit: the supervisor proves to the middleware that the call is authorized for the specific middleware identity, and the supervisor verifies it is calling the intended middleware service. -Binding IDs may be bare (`anonymizer`) or namespaced with `/` (`nvidia/anonymizer`, `acme/security/pii-redactor`). Empty path segments are invalid, so `/foo`, `foo/`, and `foo//bar` are rejected. The `openshell/` namespace is reserved for built-in OpenShell middleware, such as `openshell/regex` or `openshell/sigv4`. Policy config map keys remain stable local identities for metadata namespacing and diagnostics; the `middleware` field selects the binding. +Middleware names may be bare (`anonymizer`) or namespaced with `/` (`nvidia/anonymizer`, `acme/security/pii-redactor`). Empty path segments are invalid, so `/foo`, `foo/`, and `foo//bar` are rejected. The `openshell/` namespace is reserved for built-in OpenShell middleware, such as `openshell/regex`. Policy config map keys remain stable local identities for metadata namespacing and diagnostics; the `middleware` field selects the implementation name. Built-in middleware ships in the supervisor binary and needs no external registration. Supervisors install built-in bindings before attempting external connections. -At gateway startup, OpenShell connects to every registered service and calls `Describe`. Startup rejects unavailable or invalid services, duplicate binding IDs across services, and external claims in the reserved `openshell/` namespace. Sandbox policy creation and update call the owning service's `ValidateConfig` before persistence. +At gateway startup, OpenShell connects to every registered service and calls `Describe`. Startup rejects unavailable or invalid services, duplicate operation/phase pairs within a manifest, duplicate registration names, and external claims in the reserved `openshell/` namespace. Sandbox policy creation and update call the owning service's `ValidateConfig` before persistence. Supervisors receive policy plus the external service registrations required by the effective policy through the existing `GetSandboxConfig` response. Built-in registrations are not delivered because they are already installed in-process. The gateway stays off the request hot path; supervisors connect to the required services and invoke them directly. @@ -376,7 +418,7 @@ Multitenancy is handled by OpenShell policy selection, not by giving middleware Policy decides which middleware runs for which traffic, how it is configured, and what happens on failure. Middleware configs live once in the top-level `network_middlewares` map, represented as `map` in `SandboxPolicy`. Each map key is the stable policy-local identity. Each config selects destination hosts directly through `endpoints.include` and `endpoints.exclude`; network policies and endpoints do not carry middleware attachment lists. -A middleware config may include an optional human-readable `name`, which defaults to the map key and does not replace that key as the config identity. `middleware` is the stable binding ID exposed by a built-in or by an external service's `Describe` response. Different map keys may reference the same binding and run as separate stages with different selectors or configuration. +A middleware config may include an optional human-readable `name`, which defaults to the map key and does not replace that key as the config identity. `middleware` is a built-in or operator-owned registration name. Different map keys may reference the same implementation and run as separate stages with different selectors or configuration. Each entry supplies implementation-owned configuration, `on_error` behavior, numeric `order`, and endpoint selectors. `fail_closed` is the default. `order` defaults to `0` and must be unique across the complete policy, even when selectors do not overlap, so policies with multiple configs normally set it explicitly. OpenShell validates the structure and asks the owning implementation to `ValidateConfig` before the gateway persists a policy. @@ -436,7 +478,7 @@ Beyond allow/deny and transformation, middleware emits string metadata (for exam Because `HTTP_REQUEST/PRE_CREDENTIALS` runs before route selection and credential injection, v1 does not guarantee that metadata visible at this hook includes the final routed model or upstream route. Budget-style middleware that needs post-call status, final route/model, content length, or token usage needs a later metadata-only notification hook such as `HttpResponse/completed`; that hook is listed as a future extension in the [protocol-extensions appendix](appendices/protocol-extensions.md#additional-operation-phases), not part of the v1 request hook. -The namespace is the policy-local middleware config map key, not the optional human-readable `name` or the binding ID. This means two configs that use the same binding still produce separate metadata buckets, and changing a display label or the registered service behind a binding does not rename downstream annotations. +The namespace is the policy-local middleware config map key, not the optional human-readable `name` or implementation name. This means two configs that use the same implementation still produce separate metadata buckets, and changing a display label does not rename downstream annotations. ### Audit and logging @@ -444,7 +486,7 @@ A middleware decision is observable sandbox behavior, so it is recorded as an OC > **Update in PR #2477 - WebSocket middleware:** The coverage-boundary event below is new. It distinguishes an unsupported operation or message type from a middleware invocation or failure. -- **Per-invocation decisions** are `HttpActivity` events, since middleware is an L7 enforcement point. Each stage records the policy-local config key, validated binding ID, decision, transformation state, latency, and policy and endpoint context. Allowed requests are `Informational`; denials are `Medium`. +- **Per-invocation decisions** are `HttpActivity` events, since middleware is an L7 enforcement point. Each stage records the policy-local config key, attached middleware name, decision, transformation state, bounded byte counts, and policy and endpoint context. - **Enforcement failures and bypasses** also emit `DetectionFinding` events. Required-stage failures, invalid responses, uninspectable traffic with a required stage, and body-aware policy evaluation failures are `High`. A `fail_open` bypass and uninspectable traffic allowed because every matching stage is `fail_open` are still findings so operators can alert on reduced enforcement. - **Coverage boundaries** emit informational `NetworkActivity` events separately from invocations and failures. `binding_not_selected` records an attached config whose manifest lacks the WebSocket binding. `unsupported_message_type` records binary pass-through for an active stage with its internal config identity, logical sequence, message class, and size. - **Configuration events** are `ConfigStateChange` events: middleware registration validation, registry reload success or failure, and policy validation outcome. @@ -453,7 +495,7 @@ These events must never leak the content they describe. The OCSF JSONL may be sh - Raw request content, matched values, redacted spans, and service-config secrets are never logged. - Built-ins may preserve contract-defined audit-safe reasons and finding fields. Operator-run reason text, finding text, mutation errors, and diagnostic metadata are untrusted input. OpenShell replaces or omits them in denied responses and security logs, using stable platform-owned messages derived from the validated binding and failure category. -- Events carry only safe summaries: policy-local config keys, validated binding IDs, decisions, latency, platform-owned failure categories, and aggregate counts. +- Events carry only safe summaries: policy-local config keys, attached middleware names, decisions, platform-owned failure categories, bounded byte counts, and aggregate counts. Response content, protected headers, and free-form middleware reasons are omitted. This mirrors the middleware response contract, which already forbids the service from returning raw matched values. @@ -463,17 +505,17 @@ Supervisor egress middleware stays opt-in throughout: until a policy declares a > **Update in PR #2477 - WebSocket middleware:** Phase 1 now also includes the forward-text WebSocket operation, bounded WebSocket messages, and the WebSocket binding for the built-in regex middleware. -**Phase 1 - research-preview contract and execution.** Define `openshell.middleware.v1` with `Describe`, `ValidateConfig`, unary `EvaluateHttpRequest`, and forward-text `EvaluateWebSocketSession`; ship the example `openshell/regex` built-in; and support statically registered operator-run services. Policy uses a top-level selector-based `network_middlewares` map with stable config keys, unique numeric `order`, per-stage `on_error`, bounded bodies and messages, bounded RPC timeouts, atomic header mutations, post-transformation policy re-evaluation, and OCSF observability. Gateway startup validates external manifests, policy writes validate implementation-owned config, effective sandbox config carries only required external registrations, and supervisors install policy plus registry as one last-known-good runtime generation. Phase 1 requires encrypted authenticated transport for normal use but temporarily permits plaintext `http://` only with explicit `allow_insecure = true` for trusted local development or isolated research. OpenShell warns and emits auditable configuration state whenever that exception is used. +**Phase 1 - research-preview contract and execution.** Define `openshell.middleware.v1` with `Describe`, `ValidateConfig`, unary `EvaluateHttpRequest`, forward-text `EvaluateWebSocketSession`, and bidirectional `HttpResponsePreReturn.Evaluate`; ship the example `openshell/regex` built-in and one response-capable operator example; and support statically registered operator-run services. Policy uses a top-level selector-based `network_middlewares` map with stable config keys, unique numeric `order`, per-stage `on_error`, bounded inputs, bounded RPC timeouts, atomic direction-specific header mutations, post-request-transformation policy re-evaluation, and OCSF observability. Gateway startup validates external manifests, policy writes validate implementation-owned config, and supervisors install policy plus registry as one last-known-good runtime generation. Normal use requires TLS and bearer authentication; plaintext `http://` requires explicit `allow_insecure_transport = true` for trusted local development or isolated research. -**Phase 2 - mandatory authenticated encryption.** Remove plaintext middleware transport and remove `allow_insecure`. Every external connection must provide transport confidentiality and authenticate the intended service, with the final mechanism and credential delivery model defined by follow-up protocol work. Because phase 1 is explicitly a research preview, removing its insecure escape hatch is an intentional breaking change and does not create a long-term compatibility obligation. Operator-run service deployment otherwise keeps the same binding, policy, validation, delivery, reload, and invocation model. +**Phase 2 - transport hardening.** Add stronger service identity and rotation where deployments require it, including possible mTLS and overlapping signing-key rotation. Operator-run service deployment otherwise keeps the same policy, validation, delivery, reload, and invocation model. ### Backwards compatibility and migration -Existing sandbox policies and gateway configs that declare no middleware remain valid and pay no per-request cost. Middleware configs that opt into phase 1 plaintext are intentionally temporary: they must migrate to authenticated encrypted endpoints before phase 2 because `allow_insecure` and plaintext support will be removed. The research-preview contract may make other breaking changes before stability. +Existing sandbox policies and gateway configs that declare no middleware remain valid and pay no per-request cost. Plaintext registrations remain an explicit unauthenticated development exception through `allow_insecure_transport`; production and shared deployments use authenticated TLS. The research-preview contract may make breaking changes before stability. ### Research preview -The first release is a research preview. The contract, policy surface, and scope are provisional and may change without the usual compatibility guarantees. Plaintext is a phase 1 exception, not part of the stable design. Production and shared deployments must use authenticated encrypted transport, and phase 2 removes the exception entirely (see [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication)). The goal is to validate the contract and operational model through early experiments with a built-in middleware plus a small number of trusted external services before committing to long-term stability. +The first release is a research preview. The contract, policy surface, and scope are provisional and may change without the usual compatibility guarantees. Production and shared deployments must use authenticated encrypted transport. Plaintext registration is only for trusted local or isolated research environments. The goal is to validate the contract and operational model through a built-in request middleware and trusted operator-run request and response services before committing to long-term stability. ## Risks @@ -487,7 +529,7 @@ Adding a synchronous, content-aware hook to the egress path has real costs. The - **No OpenShell-side rate limiting.** OpenShell bounds concurrent middleware work and buffered memory, but does not throttle fast calls. A middleware that is slow, overloaded, or unavailable is handled by admission backpressure, its timeout, and `on_error`, so operators must still size, scale, and protect the service. - **Trusting an unsandboxed service with raw content.** Middleware receives raw request payloads, and OpenShell does not sandbox it, verify its behavior, or prevent it from mishandling or exfiltrating what it inspects. A buggy or malicious middleware is a direct data-exposure path. Trust in the middleware is the operator's responsibility, the same as trust in a sandbox image, but the blast radius here is in-flight request content. - **A false sense of coverage.** The hook runs only on traffic OpenShell terminates and parses. Opaque TCP or TLS passthrough, encrypted or otherwise opaque bodies, endpoints outside every selector, and content the middleware fails to detect can still leave without effective inspection. Policy validation rejects selector overlap with `tls: skip`, and runtime uninspectability follows the matching chain's failure policy, but detection correctness and traffic outside the selected host set remain inherent limitations. -- **Phase 1 plaintext is risky.** The research-preview exception permits plaintext gRPC only with explicit `allow_insecure = true`. Because middleware can allow, deny, or transform egress, an impersonated or eavesdropped service is a policy-enforcement bypass, not just an observability gap. The exception is unsuitable for shared or untrusted networks, produces an explicit warning and audit event, and is removed in phase 2. See [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication). +- **Plaintext is risky.** The research-preview exception permits plaintext gRPC only with explicit `allow_insecure_transport = true`. Because middleware can transform egress, an impersonated or eavesdropped service is a policy-enforcement bypass, not just an observability gap. The exception is unsuitable for shared or untrusted networks and produces an explicit startup warning. - **Added surface to build, version, and maintain.** A new gRPC contract, policy schema, gateway configuration table, and manifest handshake are all long-lived surfaces with compatibility obligations, and middleware chains add ordering semantics operators must reason about. The research-preview framing keeps the contract provisional for now, but the long-term maintenance cost is real and is the main argument for keeping v1 deliberately small. The cost of *not* doing this is leaving content-level egress control entirely outside OpenShell: operators who need to redact, block, or annotate outbound content based on what it contains would have to build bespoke proxies around the sandbox, losing the policy integration, audit, and trust boundary the supervisor already provides. @@ -519,30 +561,30 @@ This section closes the current review themes. > **Update in PR #2477 - WebSocket middleware:** The operation-scope and failure-behavior decisions below now include WebSocket bindings, client text messages, binary pass-through, and capability coverage. - **Middleware naming.** Use the feature name "supervisor middleware." The first operation family is egress middleware, but the higher-level feature name stays extensible for future supervisor hooks. The service can inspect, transform, deny, and annotate, so narrower names such as "transformer" or "request processor" describe only part of the contract. -- **Middleware binding IDs.** Services own stable binding IDs and policy selects them through the `middleware` field. Gateway registration names are diagnostic only. Binding IDs use `/` for namespaces, `openshell/` is reserved for built-ins, and empty path segments are invalid. +- **Implementation naming.** Policy selects built-ins or operator-owned registration names through the `middleware` field. Names may use `/` for namespaces, `openshell/` is reserved for built-ins, and empty path segments are invalid. A service manifest advertises typed operation and phase pairs rather than separate binding IDs. - **Operation naming.** Use typed operation and phase enums such as `HTTP_REQUEST/PRE_CREDENTIALS`. The operation describes the middleware API payload, and the phase describes the proxy position. Later protocols can add typed operations such as WebSocket message or TCP connect without renaming the v1 hook. -- **Operation scope of v1.** `HTTP_REQUEST/PRE_CREDENTIALS` applies to every HTTP/1.x request that OpenShell terminates and parses, whether or not the endpoint declares a `protocol`; WebSocket upgrade requests are included. `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` applies only to complete client-to-upstream text messages for attachments whose manifest advertises it. Binary and return-path messages, HTTP/2, HTTP/3, opaque TCP, and `tls: skip` traffic are excluded from those operation bindings. +- **Operation scope of v1.** `HTTP_REQUEST/PRE_CREDENTIALS` applies to parsed HTTP/1.x requests, including WebSocket upgrades. `HTTP_RESPONSE/PRE_RETURN` applies to final non-`1xx` HTTP/1.x responses before sandbox delivery and excludes `101` upgrades. `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` applies to complete client-to-upstream text messages. Binary and return-path WebSocket messages, HTTP/2, HTTP/3, opaque TCP, and `tls: skip` traffic remain excluded. - **Route selection and forwarding.** V1 has no `forward_to` decision. Middleware never makes the upstream call. Future route-selection hooks may choose among OpenShell-managed destinations, such as model routes, but must not become arbitrary external endpoint rewrites. - **SigV4/request signing.** AWS SigV4 belongs to a restricted built-in `HttpRequest/post_credentials` hook, not external `HttpRequest/pre_credentials` middleware. The middleware can be configured by policy, but it must run in-process with supervisor host capabilities so it can strip placeholder signatures and sign with real supervisor-resolved credentials without exposing those credentials over the external middleware contract. -- **Composability and ordering.** Middleware is chainable and ordered by ascending numeric `order`. Order values must be unique across the policy. A stage receives the previous stage's transformed body and header mutations; `deny` short-circuits the chain; and different config map keys may invoke the same binding as separate stages. -- **Header mutation.** Headers preserve duplicates and wire order. External writes and removals may target visible end-to-end headers without a required prefix. Writes support append, overwrite, or skip. Credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers remain protected. Each stage's mutations are atomic. -- **Finding shape.** Findings never include matched values or raw content. Built-ins may provide contract-defined audit-safe labels. Operator-run text and metadata are untrusted and are replaced or omitted in security outputs in favor of validated binding IDs, platform labels, and aggregate counts. +- **Composability and ordering.** Middleware is chainable and ordered by ascending numeric `order`. Order values must be unique across the policy. A stage receives the previous stage's transformed body and header mutations; request and WebSocket denial short-circuits the chain; and different config map keys may invoke the same implementation as separate stages. +- **Header mutation.** Headers preserve duplicates and wire order. External writes and removals may target visible end-to-end fields without a required prefix. Writes support append, overwrite, or skip. One shared validator and atomic applicator selects request-, response-, or trailer-specific protected fields. +- **Finding shape.** Findings never include matched values or raw content. Built-ins may provide contract-defined audit-safe labels. Operator-run text and metadata are untrusted and are replaced or omitted in security outputs in favor of attached registration names, platform labels, and aggregate counts. - **Actor data.** Actor process data is optional and per-connection. Middleware must treat it as context, not a reliable per-request identity or authorization input. - **Metadata namespacing.** Metadata is stored under the policy-local middleware config map key rather than the optional human-readable name. This prevents collisions without a central key registry and lets two configs using the same implementation emit independent metadata. - **Selector-only placement.** V1 uses only config-level `endpoints.include` and `endpoints.exclude` selectors. Policy-level and endpoint-level attachment lists are not part of the schema. Selection is independent of the network rule that admitted the request and therefore remains stable after effective-policy composition. -- **Failure behavior.** Middleware errors, timeouts, malformed responses, and over-cap inspectable payloads use `on_error` after an operation binding is selected; `fail_closed` is the default. An absent operation binding and binary WebSocket messages are capability coverage states, not failures, and pass with informational telemetry under both error modes. -- **Limits.** V1 caps policies at 10 middleware configs, selectors at 32 combined patterns per config, bodies at 4 MiB, findings at 32 per stage, and all non-body request and result fields at the public envelope limits in the contract section. +- **Failure behavior.** Middleware errors, timeouts, malformed results, and over-cap inspectable payloads use `on_error` after an operation binding is selected; `fail_closed` is the default. Before response commitment it returns canonical `502 response_delivery_failed`; after commitment it aborts delivery. An absent operation binding and binary WebSocket messages are capability coverage states, not failures. +- **Limits.** V1 caps policies at 10 middleware configs, selectors at 32 combined patterns per config, logical payloads at 4 MiB, response stream units at 64 KiB, findings at 32 per stage, and non-payload fields at the public envelope limits. - **Delivery and reload.** `GetSandboxConfig` delivers only external registrations required by the effective policy. Built-ins are installed locally. Supervisors prepare candidate policy and registry state off-path, swap them as one generation, reuse connections for policy-only changes, and preserve the complete last-known-good runtime on failure. -- **Chunked and compressed bodies.** V1 operates on bounded bytes OpenShell can buffer safely. Known over-cap content length may fail open before consumption when every affected stage permits it. Chunked overflow after consumption is denied because the raw stream cannot be resumed. Compressed bodies remain opaque unless a binding explicitly supports them. +- **Chunked and compressed bodies.** Request V1 operates on bounded bytes OpenShell can buffer safely. Known over-cap content length may fail open before consumption; chunked request overflow after consumption is denied because the raw stream cannot be resumed. Response V1 normalizes content-length, chunked, and close-delimited identity-coded bodies; non-identity content coding allows headers-only inspection but not body inspection. - **Post-transformation enforcement.** Every body replacement is re-evaluated by body-aware GraphQL, JSON-RPC, or MCP policy before the next stage or upstream. An enforced denial blocks. In audit mode, a denial is logged, the remaining chain stops, and the transformed request is forwarded. Evaluation failure or an unparseable replacement is a hard denial. Middleware deny and `fail_closed` remain blocking regardless of endpoint audit mode. -- **Trust boundary and phases.** Stable external middleware transport requires confidentiality and service authentication. Phase 1 may temporarily allow plaintext only with explicit `allow_insecure = true`, a warning, and an audit event in trusted local or isolated research environments. Phase 2 removes plaintext and `allow_insecure` as an intentional research-preview breaking change. +- **Trust boundary and phases.** Stable external middleware transport requires confidentiality and service authentication. Plaintext requires explicit `allow_insecure_transport = true`, carries no bearer credential, and emits a startup warning; use it only in trusted local or isolated research environments. - **Multitenancy.** OpenShell controls middleware application through policy selection. A middleware may receive sandbox and policy context for audit, but OpenShell does not define a middleware-owned tenant grouping model in v1. - **API maturity qualifier.** Use `openshell.middleware.v1`, not `v1alpha1`. The project is already alpha-stage; the RFC labels this contract as a research preview, so an additional per-contract alpha package adds little. ### Explicit deferrals - **Provider-profile middleware.** V1 middleware configs live in sandbox policy, not provider profiles. Provider-supplied network policies can be targeted after effective policy assembly. Provider-profile opt-ins for built-in middleware such as `openshell/sigv4`, and reusable cross-sandbox middleware profiles, are follow-up design work. -- **Authenticated transport mechanism.** Phase 2 requires authenticated encrypted transport. The exact choice between mTLS, TLS plus caller authentication, or an equivalent mechanism, including credential delivery and rotation, is follow-up protocol work. +- **Transport hardening.** TLS plus exact-audience gateway-signed bearer tokens is implemented. mTLS and overlapping signing-key rotation remain follow-up work. - **Health checks.** V1 relies on connection establishment, `Describe`, per-request invocation, timeout, `on_error`, and registry polling. A dedicated health RPC can improve alerting later but is not required for correctness. - **Registration ergonomics and ownership.** V1 middleware registration is an operator concern: middleware services are declared in gateway configuration and changing the registered set requires a gateway restart. Runtime user-managed registration, CLI/API helpers, SDK helpers, and an agent skill for scaffolding or registering middleware are useful follow-ups after the policy and service contract stabilize. - **Post-call budget reconciliation.** Budget-style middleware that needs final route/model, status, content length, or token usage needs a metadata-only hook such as `HttpResponse/completed`. That hook is listed as a future extension and is not part of the v1 request hook. diff --git a/rfc/0009-supervisor-middleware/appendices/extension-authentication.md b/rfc/0009-supervisor-middleware/appendices/extension-authentication.md index d73a7aa9bf..48b1cb85a8 100644 --- a/rfc/0009-supervisor-middleware/appendices/extension-authentication.md +++ b/rfc/0009-supervisor-middleware/appendices/extension-authentication.md @@ -2,7 +2,7 @@ > This is an appendix to the [RFC](../README.md). Please familiarize yourself with the RFC before reading this. -The RFC body left the authenticated-transport mechanism as follow-up protocol work: it required confidentiality plus authentication of the intended middleware service, described a phase 1 `allow_insecure` escape hatch, and deferred the phase 2 choice between mTLS, TLS plus explicit caller authentication, or an equivalent. This appendix records the mechanism that was actually built for alpha. It supersedes the body's transport-authentication and `allow_insecure` paragraphs; the rest of the body is unchanged. +Earlier RFC revisions left the authenticated-transport mechanism as follow-up protocol work: they required confidentiality plus authentication of the intended middleware service, described a phase 1 `allow_insecure` escape hatch, and deferred the phase 2 choice between mTLS, TLS plus explicit caller authentication, or an equivalent. This appendix records the mechanism built for alpha and explains the current contract reflected in the RFC body. Related: [protocol-extensions.md](protocol-extensions.md#middleware-authentication). diff --git a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md index d8ae31bdba..18f2a5951b 100644 --- a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md +++ b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md @@ -2,11 +2,11 @@ > This is an appendix to the [RFC](../README.md). Please familiarize yourself with the RFC before reading this. -**Update in PR #2477 - WebSocket middleware:** V1 now includes a forward-text WebSocket operation. The updated text below separates this implemented operation from future HTTP streaming and WebSocket return-path operations. +**Updates in PR #2477 and issue #2691:** V1 includes a forward-text WebSocket operation and HTTP response pre-return streaming. The text below separates those implemented operations from future HTTP request streaming and WebSocket return-path work. -The v1 contract is intentionally minimal: one buffered unary HTTP request hook and one forward-text WebSocket message hook, each with an `allow`/`deny` decision plus optional transformed content, findings, and metadata. This appendix records extensions the proto should not preclude, so v1 stays small without painting future work into a corner. None of these are committed; they exist to validate that the v1 shape is forward-compatible. +The v1 contract is intentionally operation-specific: one buffered unary HTTP request hook, one HTTP response pre-return stream, and one forward-text WebSocket message hook. This appendix records extensions the protocol should not preclude. None of the future shapes below are commitments. -## Streaming +## HTTP Request Streaming The v1 `EvaluateHttpRequest` RPC is unary. The supervisor buffers the bounded request body, sends one `HttpRequestEvaluation`, and receives one `HttpRequestResult`. Streaming is deliberately left out of that method: if OpenShell later needs chunked payload transport or incremental processing, it should add a separate operation-specific method rather than changing `EvaluateHttpRequest` cardinality. @@ -44,19 +44,19 @@ A cleaner phased design using a `oneof` over `context` and `body_chunk`, in the ## Additional operation phases -> **Update in PR #2477 - WebSocket middleware:** This section now records `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` as implemented. It keeps `WEBSOCKET_MESSAGE/PRE_RETURN` as a reserved future operation. +> **Updates in PR #2477 and issue #2691:** This section records `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` and `HTTP_RESPONSE/PRE_RETURN` as implemented. `WEBSOCKET_MESSAGE/PRE_RETURN` remains reserved. -V1 supports `HTTP_REQUEST/PRE_CREDENTIALS` and forward-text `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. The same service interface can host more operations, each advertised through the `Describe` manifest and invoked through an operation-specific method. Each operation and phase pair encodes a different position in the proxy flow: +V1 supports `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and forward-text `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. A manifest advertises typed operation and phase pairs, while operation-specific RPCs preserve each protocol's lifecycle: - `Connection/before_policy` / `HttpRequest/before_policy` - *before* network/L7 policy admits the request, for earlier classification. Riskier, because request content reaches a service before policy has allowed the request. - `HTTP_REQUEST/PRE_CREDENTIALS` (v1) - after policy admits the request, before credential injection. - `HttpRequest/post_credentials` - after credential injection, immediately before the relay writes the request upstream. This hook is credential-visible, so it is built-in-only: OpenShell marks it as a restricted hook and rejects any externally registered middleware that advertises it during manifest validation. The motivating use is request signing that must run after credentials are injected - for example a built-in `openshell/sigv4` that strips placeholder-signed AWS headers and signs the finalized request with supervisor-resolved credentials just before it is sent upstream. - `HttpResponse/completed` - after an upstream request completes, emit metadata such as status, content length, selected route, selected model, and model usage if available. This is notification-only: no body, no transformation, and no allow/deny verdict. It would let reservation-style budget middleware reconcile a pre-dispatch decision without introducing response-body inspection. -- `HttpResponse/before_return` - on the return path, after the upstream responds and before the response reaches the sandbox; inspect or redact upstream responses. +- `HTTP_RESPONSE/PRE_RETURN` (v1) - after the final non-`1xx` upstream response head and before sandbox delivery. `HttpResponsePreReturn.Evaluate` supports preflight `SKIP` or `INSPECT`, headers-only inspection, bounded whole-body bytes, normalized lockstep stream bytes, body end, normalized trailers, and typed session termination. V1 has no successful response-body denial action. - `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` (v1 forward text) - after a WebSocket upgrade, on each complete client text message before credential placeholder rewriting. Before upstream contact, a concurrent preflight lets each selected stage inspect, voluntarily skip, or authoritatively deny the upgrade. Explicit denial takes precedence over failures and is enforced independently of `on_error`; OpenShell best-effort ends every still-writable opened stage stream with the typed terminal reason. An attached implementation without this binding is not selected and records coverage rather than applying `on_error`. Binary messages pass without inspection, consume a logical sequence, and record unsupported-message coverage for active stages. - `WEBSOCKET_MESSAGE/PRE_RETURN` - on complete upstream messages before they return to the workload. The enum value is reserved, but manifests advertising it are rejected until return-path inspection is implemented. -Pre-policy phases run earliest, the two request phases bracket credential injection, response notifications and response phases run after the upstream call, and message phases run later on the parsed relay. V1 implements only the two pre-credentials pairs above. `HttpRequest/post_credentials` is the nearest planned request-path follow-up and is kept built-in-only because it sees injected credentials; `HttpResponse/completed` is a separate future notification hook for metadata-only post-call reconciliation. +Pre-policy phases run earliest, request phases bracket credential injection, response phases run after the upstream call, and message phases run on the parsed relay. V1 implements the three explicitly marked pairs above. `HttpRequest/post_credentials` remains a built-in-only candidate because it would see injected credentials. `HttpResponse/completed` remains a separate future notification hook for metadata-only post-call reconciliation. ## Semantic context @@ -68,26 +68,22 @@ ICAP-style previewing: send only the first N bytes so the service can decide whe ## Portable feature contracts and binding -A future version can introduce named feature contracts, such as `pii-redaction`, with a mapping from that portable contract to a concrete service binding. Policy would then stay portable across interchangeable implementations. V1 references a service-owned binding ID directly and defers this additional indirection. +A future version can introduce named feature contracts, such as `pii-redaction`, with a mapping from that portable contract to a concrete registered implementation. Policy would then stay portable across interchangeable implementations. V1 references a built-in or operator-owned registration name and defers this additional indirection. ## Header mutation rules -V1 preserves duplicate request headers and their wire order. Before an external invocation, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. Results return ordered `write` and `remove` mutations for visible end-to-end fields without a required prefix. Writes support append, overwrite, and skip modes. Credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers remain protected. OpenShell validates and applies a stage's mutations atomically, so one invalid mutation discards the whole set and follows that config's `on_error` behavior. +V1 preserves duplicate headers and their wire order. Results return ordered `write` and `remove` mutations for visible end-to-end fields without a required prefix. Writes support append, overwrite, and skip modes. One shared validator and atomic applicator enforces syntax and limits, then selects request-, response-, or trailer-specific protected fields. One invalid mutation discards the whole set and follows that config's `on_error` behavior. Response middleware may add only trailer names declared during preflight. ## Middleware authentication Supervisor middleware exposes gRPC services over network endpoints. The stable transport contract requires confidentiality and authentication of the intended middleware service. Endpoint declaration, identity binding, credential material, and rotation must be explicit rather than left as deployment-specific conventions. -Phase 1 may temporarily support unauthenticated plaintext gRPC only when the operator explicitly sets `allow_insecure = true` on the middleware entry. A plaintext `http://` endpoint without this opt-in is rejected. OpenShell emits a prominent warning and records auditable configuration state whenever the exception is enabled, so insecure operation is always deliberate and visible. +OpenShell supports unauthenticated plaintext gRPC only when the operator explicitly sets `allow_insecure_transport = true` on the middleware registration. A plaintext `http://` endpoint without this opt-in is rejected. OpenShell attaches no bearer credential and emits a prominent startup warning whenever the exception is enabled. This mode is suitable only for trusted local development, loopback services, or isolated research environments where the middleware endpoint is not reachable by untrusted clients. It is not suitable for shared clusters, multi-tenant deployments, public networks, or any environment where inspected request content needs transport confidentiality. Without middleware authentication and transport security, network observers can read inspected request content, active attackers can impersonate the middleware service, and unauthorized clients can call the middleware directly if it is reachable. Because the middleware can allow, deny, or transform egress, service impersonation is a policy-enforcement bypass, not just an observability risk. -Phase 2 removes plaintext endpoint support and removes `allow_insecure`. Every external middleware connection must then provide authenticated encrypted transport. This is an intentional research-preview breaking change, so phase 1 plaintext configurations have no long-term compatibility guarantee and must migrate before phase 2. - -The exact phase 2 mechanism is deferred. Follow-up protocol work should choose and specify mTLS, TLS plus explicit caller authentication, or an equivalent design, including trust roots, client identity, credential delivery, certificate or key rotation, middleware identity binding, and how supervisors receive authentication material. - -The alpha mechanism that was subsequently built - TLS with optional operator-provided trust roots plus short-lived, exact-audience gateway-signed JWTs - is recorded in [extension-authentication.md](extension-authentication.md). It supersedes this section's `allow_insecure` design with `allow_insecure_transport` and narrows, but does not close, the phase 2 question: mTLS and overlapping key rotation remain deferred. +Authenticated operation uses TLS with optional operator-provided trust roots plus short-lived, exact-audience gateway-signed JWTs, as recorded in [extension-authentication.md](extension-authentication.md). mTLS and overlapping key rotation remain deferred. Even during the phase 1 plaintext exception, the hook stays before provider credential injection, and OpenShell does not forward original `Authorization`, `Cookie`, or other protected headers to middleware. This preserves the separation between content inspection and upstream credential injection while authenticated transport is completed.