feat(middleware): stream HTTP request evaluation

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
This commit is contained in:
Piotr Mlocek
2026-09-01 11:15:30 -07:00
parent df9daf7915
commit 5144c70685
17 changed files with 4519 additions and 170 deletions
+8 -5
View File
@@ -164,11 +164,14 @@ the request. Policy-local map keys identify configs, while built-in names or
operator-owned registration names identify implementations.
Built-ins run in-process against a borrowed view of the chain's current HTTP
request state. Operator services retain the bounded protobuf/gRPC contract, and
the remote adapter materializes an owned HTTP evaluation only when a request
crosses that transport boundary. Both paths support bounded bidirectional
WebSocket sessions, so a manifest advertises capabilities independently of
transport.
request state. Operator services use a phase-specific bidirectional request
stream: preflight selects headers-only, whole-body, or lockstep byte-stream
inspection before any credential injection or upstream contact. The current
HTTP/1 relay still retains the bounded request before opening that stream.
Operator response and WebSocket hooks also use bounded bidirectional streams,
so a manifest advertises capabilities independently of transport. The request
adapter falls back to the legacy unary evaluation only when an older service
returns `UNIMPLEMENTED` for the stream.
The runtime keeps three states distinct: host selection attaches policy configs,
manifest operation and phase bindings select the active chain, and the parsed
message type determines whether that chain can inspect an individual payload.
+31 -4
View File
@@ -11,12 +11,17 @@ use tokio::sync::mpsc;
use tonic::{Request, Response, Status};
use crate::proto::{
HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, HttpResponseEvent,
HttpResponseEventResult, MiddlewareManifest, RequestContext, SupervisorMiddlewarePhase,
ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent,
WebSocketSessionEventResult,
HttpHeader, HttpRequestEvaluation, HttpRequestEvent, HttpRequestEventResult, HttpRequestResult,
HttpRequestTarget, HttpResponseEvent, HttpResponseEventResult, MiddlewareManifest,
RequestContext, SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse,
WebSocketSessionEvent, WebSocketSessionEventResult,
};
/// Transport-neutral result stream for one HTTP request middleware stage.
pub type HttpRequestResultStream = Pin<
Box<dyn tokio_stream::Stream<Item = Result<HttpRequestEventResult, Status>> + Send + 'static>,
>;
/// Transport-neutral result stream for one HTTP response middleware stage.
pub type HttpResponseResultStream = Pin<
Box<dyn tokio_stream::Stream<Item = Result<HttpResponseEventResult, Status>> + Send + 'static>,
@@ -49,6 +54,15 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync {
request: Request<HttpRequestEvaluation>,
) -> Result<Response<HttpRequestResult>, Status>;
async fn open_http_request_pre_credentials(
&self,
_requests: mpsc::Receiver<HttpRequestEvent>,
) -> Result<HttpRequestResultStream, Status> {
Err(Status::unimplemented(
"middleware does not implement HTTP request pre-credentials evaluation",
))
}
async fn open_websocket_session(
&self,
requests: mpsc::Receiver<WebSocketSessionEvent>,
@@ -246,6 +260,19 @@ pub trait InProcessMiddleware: Send + Sync {
request: HttpRequestView<'_>,
) -> Result<HttpRequestResult>;
/// Open one HTTP request pre-credentials stream.
///
/// Built-in middleware may keep using the borrowed request method. Remote
/// request middleware uses this stream through the transport adapter.
async fn open_http_request_pre_credentials(
&self,
_requests: mpsc::Receiver<HttpRequestEvent>,
) -> std::result::Result<HttpRequestResultStream, Status> {
Err(Status::unimplemented(
"middleware does not implement HTTP request pre-credentials evaluation",
))
}
/// Open one persistent WebSocket middleware session.
///
/// HTTP-only implementations may keep the default unsupported response.
@@ -5,6 +5,7 @@
pub mod headers;
mod remote;
mod request;
mod response;
mod websocket;
@@ -40,8 +41,8 @@ use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore};
use tonic::{Request, Response as TonicResponse, Status as TonicStatus};
pub use openshell_core::middleware::{
HttpRequestView, HttpResponseResultStream, InProcessMiddleware, SupervisorMiddlewareEndpoint,
WebSocketResponseStream,
HttpRequestResultStream, HttpRequestView, HttpResponseResultStream, InProcessMiddleware,
SupervisorMiddlewareEndpoint, WebSocketResponseStream,
};
pub type MiddlewareService =
dyn SupervisorMiddleware<EvaluateWebSocketSessionStream = WebSocketResponseStream>;
@@ -182,6 +183,15 @@ impl InProcessMiddleware for EndpointInProcessAdapter {
.map_err(|error| miette!("{error}"))
}
async fn open_http_request_pre_credentials(
&self,
requests: tokio::sync::mpsc::Receiver<openshell_core::proto::HttpRequestEvent>,
) -> std::result::Result<HttpRequestResultStream, tonic::Status> {
self.endpoint
.open_http_request_pre_credentials(requests)
.await
}
async fn open_websocket_session(
&self,
requests: tokio::sync::mpsc::Receiver<openshell_core::proto::WebSocketSessionEvent>,
@@ -612,6 +622,7 @@ impl MiddlewareDispatch {
async fn evaluate_http_request(
&self,
request: HttpRequestView<'_>,
max_payload_bytes: usize,
) -> std::result::Result<tonic::Response<openshell_core::proto::HttpRequestResult>, tonic::Status>
{
match self {
@@ -620,7 +631,14 @@ impl MiddlewareDispatch {
.await
.map(tonic::Response::new)
.map_err(|error| tonic::Status::invalid_argument(error.to_string())),
Self::Grpc(service) => service.evaluate_http_request(request).await,
Self::Grpc(service) => {
match request::evaluate_remote_request(service, request, max_payload_bytes).await {
Err(status) if status.code() == tonic::Code::Unimplemented => {
service.evaluate_http_request_compat(request).await
}
result => result,
}
}
}
}
@@ -1754,7 +1772,9 @@ impl ChainRunner {
let mut result = match call_with_timeout(
entry.timeout.min(remaining),
"EvaluateHttpRequest",
service.service.evaluate_http_request(request),
service
.service
.evaluate_http_request(request, entry.max_payload_bytes),
)
.await
{
@@ -3,14 +3,15 @@
use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::middleware::{
HttpRequestView, HttpResponseResultStream, SupervisorMiddlewareEndpoint,
WebSocketResponseStream,
HttpRequestResultStream, HttpRequestView, HttpResponseResultStream,
SupervisorMiddlewareEndpoint, WebSocketResponseStream,
};
use openshell_core::proto::middleware::v1::http_request_pre_credentials_client::HttpRequestPreCredentialsClient;
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, HttpResponseEvent, MiddlewareManifest,
ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent,
HttpRequestEvaluation, HttpRequestEvent, HttpRequestResult, HttpResponseEvent,
MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent,
};
use openshell_extension_core::{
BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust,
@@ -78,8 +79,9 @@ impl GrpcMiddlewareService {
.await
}
/// Materialize an owned protobuf evaluation immediately before transport.
pub async fn evaluate_http_request(
/// Call the compatibility unary request RPC when the streaming service is
/// not implemented by an existing operator service.
pub async fn evaluate_http_request_compat(
&self,
request: HttpRequestView<'_>,
) -> std::result::Result<Response<HttpRequestResult>, Status> {
@@ -104,6 +106,16 @@ impl GrpcMiddlewareService {
self.service.open_websocket_session(receiver).await
}
/// Open a remote HTTP request pre-credentials stream through the gRPC adapter.
pub async fn open_http_request_pre_credentials(
&self,
receiver: tokio::sync::mpsc::Receiver<HttpRequestEvent>,
) -> std::result::Result<HttpRequestResultStream, Status> {
self.service
.open_http_request_pre_credentials(receiver)
.await
}
/// Open a remote HTTP response pre-return stream through the gRPC adapter.
pub async fn open_http_response_pre_return(
&self,
@@ -116,6 +128,7 @@ impl GrpcMiddlewareService {
#[derive(Clone)]
pub struct RemoteMiddlewareService {
client: SupervisorMiddlewareClient<ExtensionChannel>,
request_client: HttpRequestPreCredentialsClient<ExtensionChannel>,
response_client: HttpResponsePreReturnClient<ExtensionChannel>,
}
@@ -147,6 +160,9 @@ impl RemoteMiddlewareService {
client: SupervisorMiddlewareClient::new(channel.clone())
.max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES)
.max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES),
request_client: HttpRequestPreCredentialsClient::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),
@@ -194,6 +210,20 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService {
Ok(Box::pin(responses))
}
async fn open_http_request_pre_credentials(
&self,
receiver: tokio::sync::mpsc::Receiver<HttpRequestEvent>,
) -> std::result::Result<HttpRequestResultStream, Status> {
let mut client = self.request_client.clone();
let responses = client
.evaluate(Request::new(tokio_stream::wrappers::ReceiverStream::new(
receiver,
)))
.await?
.into_inner();
Ok(Box::pin(responses))
}
async fn open_http_response_pre_return(
&self,
receiver: tokio::sync::mpsc::Receiver<HttpResponseEvent>,
@@ -0,0 +1,674 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! HTTP request pre-credentials stream adapter for remote middleware.
use std::collections::HashMap;
use std::time::Duration;
use futures::StreamExt as _;
use tokio::sync::mpsc;
use openshell_core::middleware::{HttpRequestResultStream, HttpRequestView};
use openshell_core::proto::{
Decision, Finding, HeaderMutation, HttpRequestBodyEnd, HttpRequestBodyMode,
HttpRequestBodyUnit, HttpRequestEvent, HttpRequestEventResult, HttpRequestPreflight,
HttpRequestResult, MiddlewareExchangeProtocolError, MiddlewareSessionEnd,
MiddlewareSessionEndReason, MiddlewareSessionProtocolError, http_request_body_result,
http_request_body_transform, http_request_body_unit, http_request_event,
http_request_event_result, http_request_preflight_result, middleware_session_protocol_error,
};
use crate::remote::GrpcMiddlewareService;
const STREAM_CHANNEL_CAPACITY: usize = 1;
const MAX_STREAM_UNIT_BYTES: usize = 64 * 1024;
const MAX_STAGE_FINDINGS: usize = 32;
const MAX_STAGE_METADATA_ENTRIES: usize = 64;
pub async fn evaluate_remote_request(
service: &GrpcMiddlewareService,
request: HttpRequestView<'_>,
max_payload_bytes: usize,
) -> Result<tonic::Response<HttpRequestResult>, tonic::Status> {
let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY);
let mut responses = service.open_http_request_pre_credentials(receiver).await?;
send(
&sender,
HttpRequestEvent {
event: Some(http_request_event::Event::Preflight(HttpRequestPreflight {
context: Some(request.context().clone()),
target: Some(request.target().clone()),
headers: request.headers().to_vec(),
middleware_name: request.middleware_name().to_string(),
config: Some(request.config().clone()),
max_payload_bytes: max_payload_bytes as u64,
})),
},
)
.await?;
let Some(http_request_event_result::Result::PreflightResult(preflight)) =
next_result(&mut responses).await?.result
else {
return protocol_failure(&sender, "expected preflight result").await;
};
let Ok(mut diagnostics) = Diagnostics::new(
preflight.reason,
preflight.reason_code,
preflight.findings,
preflight.metadata,
) else {
return protocol_failure(&sender, "invalid preflight diagnostics").await;
};
let Ok(decision) = Decision::try_from(preflight.decision) else {
return protocol_failure(&sender, "invalid preflight decision").await;
};
match decision {
Decision::Deny => {
if preflight.action.is_some() {
return protocol_failure(&sender, "denied preflight must not select an action")
.await;
}
end(&sender, MiddlewareSessionEndReason::MiddlewareDenial).await;
return Ok(tonic::Response::new(diagnostics.into_result(
Decision::Deny,
Vec::new(),
false,
Vec::new(),
)));
}
Decision::Allow => {}
Decision::Unspecified => {
return protocol_failure(&sender, "preflight decision is required").await;
}
}
let Some(action) = preflight.action else {
return protocol_failure(&sender, "allowed preflight requires an action").await;
};
let inspect = match action {
http_request_preflight_result::Action::Skip(_) => {
end(&sender, MiddlewareSessionEndReason::StageSkipped).await;
return Ok(tonic::Response::new(diagnostics.into_result(
Decision::Allow,
Vec::new(),
false,
Vec::new(),
)));
}
http_request_preflight_result::Action::Inspect(inspect) => inspect,
};
let Ok(body_mode) = HttpRequestBodyMode::try_from(inspect.body_mode) else {
return protocol_failure(&sender, "invalid request body mode").await;
};
if body_mode == HttpRequestBodyMode::Unspecified {
return protocol_failure(&sender, "request body mode is required").await;
}
let header_mutations = inspect.header_mutations;
if body_mode == HttpRequestBodyMode::HeadersOnly {
end(&sender, MiddlewareSessionEndReason::Normal).await;
return Ok(tonic::Response::new(diagnostics.into_result(
Decision::Allow,
Vec::new(),
false,
header_mutations,
)));
}
let mut output = Vec::new();
let mut transformed = false;
let mut final_sequence = 0;
match body_mode {
HttpRequestBodyMode::WholeBodyBytes => {
final_sequence = 1;
match exchange_body(
&sender,
&mut responses,
1,
request.body().to_vec(),
max_payload_bytes,
&mut diagnostics,
)
.await?
{
BodyOutcome::Continue {
data,
transformed: unit_transformed,
} => {
transformed |= unit_transformed;
if !append_output(&mut output, data, max_payload_bytes) {
return protocol_failure(
&sender,
"transformed request body exceeds payload limit",
)
.await;
}
}
BodyOutcome::Deny => {
end(&sender, MiddlewareSessionEndReason::MiddlewareDenial).await;
return Ok(tonic::Response::new(diagnostics.into_result(
Decision::Deny,
Vec::new(),
false,
Vec::new(),
)));
}
}
}
HttpRequestBodyMode::StreamBytes => {
let unit_limit = max_payload_bytes.clamp(1, MAX_STREAM_UNIT_BYTES);
for (index, unit) in request.body().chunks(unit_limit).enumerate() {
let sequence = index as u64 + 1;
final_sequence = sequence;
match exchange_body(
&sender,
&mut responses,
sequence,
unit.to_vec(),
max_payload_bytes,
&mut diagnostics,
)
.await?
{
BodyOutcome::Continue {
data,
transformed: unit_transformed,
} => {
transformed |= unit_transformed;
if !append_output(&mut output, data, max_payload_bytes) {
return protocol_failure(
&sender,
"transformed request body exceeds payload limit",
)
.await;
}
}
BodyOutcome::Deny => {
end(&sender, MiddlewareSessionEndReason::MiddlewareDenial).await;
return Ok(tonic::Response::new(diagnostics.into_result(
Decision::Deny,
Vec::new(),
false,
Vec::new(),
)));
}
}
}
}
HttpRequestBodyMode::HeadersOnly | HttpRequestBodyMode::Unspecified => unreachable!(),
}
send(
&sender,
HttpRequestEvent {
event: Some(http_request_event::Event::BodyEnd(HttpRequestBodyEnd {
final_sequence,
})),
},
)
.await?;
end(&sender, MiddlewareSessionEndReason::Normal).await;
Ok(tonic::Response::new(diagnostics.into_result(
Decision::Allow,
output,
transformed,
header_mutations,
)))
}
async fn exchange_body(
sender: &mpsc::Sender<HttpRequestEvent>,
responses: &mut HttpRequestResultStream,
sequence: u64,
input: Vec<u8>,
max_payload_bytes: usize,
diagnostics: &mut Diagnostics,
) -> Result<BodyOutcome, tonic::Status> {
send(
sender,
HttpRequestEvent {
event: Some(http_request_event::Event::Body(HttpRequestBodyUnit {
sequence,
payload: Some(http_request_body_unit::Payload::Data(input.clone())),
})),
},
)
.await?;
let Some(http_request_event_result::Result::BodyResult(result)) =
next_result(responses).await?.result
else {
return protocol_failure(sender, "expected body result").await;
};
if result.sequence != sequence {
return protocol_failure(sender, "body result sequence mismatch").await;
}
if diagnostics
.extend(
result.reason,
result.reason_code,
result.findings,
result.metadata,
)
.is_err()
{
return protocol_failure(sender, "invalid body diagnostics").await;
}
let Ok(decision) = Decision::try_from(result.decision) else {
return protocol_failure(sender, "invalid body decision").await;
};
match decision {
Decision::Deny => {
if result.action.is_some() {
return protocol_failure(sender, "denied body result must not select an action")
.await;
}
Ok(BodyOutcome::Deny)
}
Decision::Allow => match result.action {
Some(http_request_body_result::Action::PassThrough(_)) => Ok(BodyOutcome::Continue {
data: input,
transformed: false,
}),
Some(http_request_body_result::Action::Transform(transform)) => {
let Some(http_request_body_transform::Replacement::Data(replacement)) =
transform.replacement
else {
return protocol_failure(sender, "transform replacement is required").await;
};
if replacement.len() > max_payload_bytes {
return protocol_failure(sender, "body replacement exceeds payload limit")
.await;
}
Ok(BodyOutcome::Continue {
data: replacement,
transformed: true,
})
}
None => protocol_failure(sender, "allowed body result requires an action").await,
},
Decision::Unspecified => protocol_failure(sender, "body decision is required").await,
}
}
enum BodyOutcome {
Continue { data: Vec<u8>, transformed: bool },
Deny,
}
struct Diagnostics {
reason: String,
reason_code: String,
findings: Vec<Finding>,
metadata: HashMap<String, String>,
}
impl Diagnostics {
fn new(
reason: String,
reason_code: String,
findings: Vec<Finding>,
metadata: HashMap<String, String>,
) -> Result<Self, tonic::Status> {
let diagnostics = Self {
reason,
reason_code,
findings,
metadata,
};
diagnostics.validate()?;
Ok(diagnostics)
}
fn extend(
&mut self,
reason: String,
reason_code: String,
findings: Vec<Finding>,
metadata: HashMap<String, String>,
) -> Result<(), tonic::Status> {
if !reason.is_empty() {
self.reason = reason;
}
if !reason_code.is_empty() {
self.reason_code = reason_code;
}
self.findings.extend(findings);
self.metadata.extend(metadata);
self.validate()
}
fn validate(&self) -> Result<(), tonic::Status> {
if self.findings.len() > MAX_STAGE_FINDINGS {
return Err(tonic::Status::invalid_argument(
"request stage returned too many findings",
));
}
if self.metadata.len() > MAX_STAGE_METADATA_ENTRIES {
return Err(tonic::Status::invalid_argument(
"request stage returned too many metadata entries",
));
}
Ok(())
}
fn into_result(
self,
decision: Decision,
body: Vec<u8>,
has_body: bool,
header_mutations: Vec<HeaderMutation>,
) -> HttpRequestResult {
HttpRequestResult {
decision: decision as i32,
reason: self.reason,
body,
has_body,
header_mutations,
findings: self.findings,
metadata: self.metadata,
reason_code: self.reason_code,
}
}
}
fn append_output(output: &mut Vec<u8>, data: Vec<u8>, max_payload_bytes: usize) -> bool {
if output.len().saturating_add(data.len()) > max_payload_bytes {
return false;
}
output.extend_from_slice(&data);
true
}
async fn next_result(
responses: &mut HttpRequestResultStream,
) -> Result<HttpRequestEventResult, tonic::Status> {
match responses.next().await {
Some(Ok(result)) => Ok(result),
Some(Err(status)) => Err(status),
None => Err(tonic::Status::unavailable(
"middleware closed request result stream before replying",
)),
}
}
async fn send(
sender: &mpsc::Sender<HttpRequestEvent>,
event: HttpRequestEvent,
) -> Result<(), tonic::Status> {
sender
.send(event)
.await
.map_err(|_| tonic::Status::unavailable("middleware closed request event stream"))
}
async fn end(sender: &mpsc::Sender<HttpRequestEvent>, reason: MiddlewareSessionEndReason) {
let event = HttpRequestEvent {
event: Some(http_request_event::Event::SessionEnd(
MiddlewareSessionEnd {
reason: reason as i32,
protocol_error: None,
},
)),
};
let _ = tokio::time::timeout(Duration::from_millis(10), sender.send(event)).await;
}
async fn protocol_failure<T>(
sender: &mpsc::Sender<HttpRequestEvent>,
message: &'static str,
) -> Result<T, tonic::Status> {
let event = HttpRequestEvent {
event: Some(http_request_event::Event::SessionEnd(
MiddlewareSessionEnd {
reason: MiddlewareSessionEndReason::ProtocolError as i32,
protocol_error: Some(MiddlewareSessionProtocolError {
domain: Some(
middleware_session_protocol_error::Domain::MiddlewareExchange(
MiddlewareExchangeProtocolError {},
),
),
}),
},
)),
};
let _ = tokio::time::timeout(Duration::from_millis(10), sender.send(event)).await;
Err(tonic::Status::invalid_argument(message))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use openshell_core::middleware::{
HttpRequestResultStream, SupervisorMiddlewareEndpoint, WebSocketResponseStream,
};
use openshell_core::proto::{
HttpRequestBodyPassThrough, HttpRequestBodyResult, HttpRequestBodyTransform,
HttpRequestEventResult, HttpRequestPreflightInspect, HttpRequestPreflightResult,
HttpRequestResult, MiddlewareManifest, RequestContext, SupervisorMiddlewarePhase,
ValidateConfigRequest, ValidateConfigResponse, http_request_body_result,
http_request_body_transform, http_request_event, http_request_event_result,
http_request_preflight_result,
};
use super::*;
#[derive(Clone, Copy)]
enum Script {
Whole,
Stream,
PassThrough,
Deny,
}
struct ScriptedEndpoint {
script: Script,
}
#[tonic::async_trait]
impl SupervisorMiddlewareEndpoint for ScriptedEndpoint {
async fn describe(
&self,
_request: tonic::Request<()>,
) -> Result<tonic::Response<MiddlewareManifest>, tonic::Status> {
Ok(tonic::Response::new(MiddlewareManifest::default()))
}
async fn validate_config(
&self,
_request: tonic::Request<ValidateConfigRequest>,
) -> Result<tonic::Response<ValidateConfigResponse>, tonic::Status> {
Ok(tonic::Response::new(ValidateConfigResponse {
valid: true,
reason: String::new(),
}))
}
async fn evaluate_http_request(
&self,
_request: tonic::Request<openshell_core::proto::HttpRequestEvaluation>,
) -> Result<tonic::Response<HttpRequestResult>, tonic::Status> {
Err(tonic::Status::unimplemented("stream-only test service"))
}
async fn open_websocket_session(
&self,
_requests: mpsc::Receiver<openshell_core::proto::WebSocketSessionEvent>,
) -> Result<WebSocketResponseStream, tonic::Status> {
Err(tonic::Status::unimplemented("request-only test service"))
}
async fn open_http_request_pre_credentials(
&self,
mut requests: mpsc::Receiver<HttpRequestEvent>,
) -> Result<HttpRequestResultStream, tonic::Status> {
let script = self.script;
let (sender, receiver) = mpsc::channel(1);
tokio::spawn(async move {
while let Some(event) = requests.recv().await {
let result = match event.event {
Some(http_request_event::Event::Preflight(_)) => {
let (decision, action) = match script {
Script::Deny => (Decision::Deny, None),
Script::Whole | Script::PassThrough => (
Decision::Allow,
Some(http_request_preflight_result::Action::Inspect(
HttpRequestPreflightInspect {
body_mode: HttpRequestBodyMode::WholeBodyBytes as i32,
header_mutations: Vec::new(),
},
)),
),
Script::Stream => (
Decision::Allow,
Some(http_request_preflight_result::Action::Inspect(
HttpRequestPreflightInspect {
body_mode: HttpRequestBodyMode::StreamBytes as i32,
header_mutations: Vec::new(),
},
)),
),
};
Some(HttpRequestEventResult {
result: Some(http_request_event_result::Result::PreflightResult(
HttpRequestPreflightResult {
decision: decision as i32,
action,
reason_code: if decision == Decision::Deny {
"blocked".to_string()
} else {
String::new()
},
..Default::default()
},
)),
})
}
Some(http_request_event::Event::Body(body)) => {
let Some(http_request_body_unit::Payload::Data(mut data)) =
body.payload
else {
let _ = sender
.send(Err(tonic::Status::invalid_argument("missing data")))
.await;
break;
};
let action = match script {
Script::Whole => {
data.reverse();
http_request_body_result::Action::Transform(
HttpRequestBodyTransform {
replacement: Some(
http_request_body_transform::Replacement::Data(
data,
),
),
},
)
}
Script::Stream => {
data.make_ascii_uppercase();
http_request_body_result::Action::Transform(
HttpRequestBodyTransform {
replacement: Some(
http_request_body_transform::Replacement::Data(
data,
),
),
},
)
}
Script::PassThrough => {
http_request_body_result::Action::PassThrough(
HttpRequestBodyPassThrough {},
)
}
Script::Deny => http_request_body_result::Action::PassThrough(
HttpRequestBodyPassThrough {},
),
};
Some(HttpRequestEventResult {
result: Some(http_request_event_result::Result::BodyResult(
HttpRequestBodyResult {
sequence: body.sequence,
decision: Decision::Allow as i32,
action: Some(action),
..Default::default()
},
)),
})
}
Some(http_request_event::Event::SessionEnd(_)) => break,
Some(http_request_event::Event::BodyEnd(_)) | None => None,
};
if let Some(result) = result
&& sender.send(Ok(result)).await.is_err()
{
break;
}
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(
receiver,
)))
}
}
async fn run(script: Script, body: &[u8], max_payload_bytes: usize) -> HttpRequestResult {
let service = GrpcMiddlewareService::from_service(Arc::new(ScriptedEndpoint { script }));
let context = RequestContext::default();
let config = prost_types::Struct::default();
let target = openshell_core::proto::HttpRequestTarget::default();
evaluate_remote_request(
&service,
HttpRequestView::new(
SupervisorMiddlewarePhase::PreCredentials,
&context,
&config,
&target,
&[],
body,
"scripted",
),
max_payload_bytes,
)
.await
.unwrap()
.into_inner()
}
#[tokio::test]
async fn whole_body_mode_transforms_one_unit() {
let result = run(Script::Whole, b"abcdef", 64).await;
assert_eq!(result.decision, Decision::Allow as i32);
assert_eq!(result.body, b"fedcba");
assert!(result.has_body);
}
#[tokio::test]
async fn stream_mode_transforms_lockstep_units() {
let body = vec![b'a'; MAX_STREAM_UNIT_BYTES + 1];
let result = run(Script::Stream, &body, body.len()).await;
assert_eq!(result.decision, Decision::Allow as i32);
assert_eq!(result.body, vec![b'A'; body.len()]);
assert!(result.has_body);
}
#[tokio::test]
async fn pass_through_does_not_report_a_body_replacement() {
let result = run(Script::PassThrough, b"unchanged", 64).await;
assert_eq!(result.decision, Decision::Allow as i32);
assert!(!result.has_body);
}
#[tokio::test]
async fn preflight_denial_needs_no_processing_action() {
let result = run(Script::Deny, b"secret", 64).await;
assert_eq!(result.decision, Decision::Deny as i32);
assert_eq!(result.reason_code, "blocked");
assert!(!result.has_body);
}
}
@@ -14,11 +14,10 @@ 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,
HttpResponseEventResult, HttpResponsePreflight, HttpResponseTrailers, MiddlewareSessionEnd,
MiddlewareSessionEndReason, 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::{
@@ -143,7 +142,7 @@ impl HttpResponseStage {
self.is_active() && self.mode != StageMode::HeadersOnly
}
async fn end(&mut self, reason: HttpResponseSessionEndReason) {
async fn end(&mut self, reason: MiddlewareSessionEndReason) {
if let Some(transport) = self.transport.take() {
let _ = tokio::time::timeout(
Duration::from_millis(10),
@@ -244,7 +243,7 @@ impl HttpResponseSession {
let stage_output = match self.finish_stage(index, deadline).await {
Ok(output) => output,
Err(failure) => {
self.end_all(HttpResponseSessionEndReason::MiddlewareFailure)
self.end_all(MiddlewareSessionEndReason::MiddlewareFailure)
.await;
return Err(failure);
}
@@ -260,12 +259,12 @@ impl HttpResponseSession {
let trailers = match self.process_trailers(trailers, deadline).await {
Ok(trailers) => trailers,
Err(failure) => {
self.end_all(HttpResponseSessionEndReason::MiddlewareFailure)
self.end_all(MiddlewareSessionEndReason::MiddlewareFailure)
.await;
return Err(failure);
}
};
self.end_all(HttpResponseSessionEndReason::Normal).await;
self.end_all(MiddlewareSessionEndReason::Normal).await;
self.session_admission.take();
Ok(HttpResponseFinish {
body_units: released,
@@ -277,7 +276,7 @@ impl HttpResponseSession {
})
}
pub async fn end(mut self, reason: HttpResponseSessionEndReason) {
pub async fn end(mut self, reason: MiddlewareSessionEndReason) {
self.end_all(reason).await;
}
@@ -605,7 +604,7 @@ impl HttpResponseSession {
failure_category: Some(response_failure_category(reason).into()),
});
stage
.end(HttpResponseSessionEndReason::MiddlewareFailure)
.end(MiddlewareSessionEndReason::MiddlewareFailure)
.await;
if stage.entry.on_error() == OnError::FailOpen {
if original.is_empty() {
@@ -620,7 +619,7 @@ impl HttpResponseSession {
}
}
async fn end_all(&mut self, reason: HttpResponseSessionEndReason) {
async fn end_all(&mut self, reason: MiddlewareSessionEndReason) {
for stage in &mut self.stages {
stage.end(reason).await;
}
@@ -661,7 +660,7 @@ impl ChainRunner {
if let Some(reason) =
collect_preflight_failure(&entry, "binding_not_described", &mut invocations)
{
end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure).await;
end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await;
return Ok(failed_preflight_outcome(
headers,
reason,
@@ -711,7 +710,7 @@ impl ChainRunner {
if let Some(reason) =
collect_preflight_failure(&entry, &reason, &mut invocations)
{
end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure)
end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure)
.await;
return Ok(failed_preflight_outcome(
headers,
@@ -727,7 +726,7 @@ impl ChainRunner {
if let Some(reason) =
collect_preflight_failure(&entry, "middleware_timeout", &mut invocations)
{
end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure)
end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure)
.await;
return Ok(failed_preflight_outcome(
headers,
@@ -748,7 +747,7 @@ impl ChainRunner {
"unexpected_response_result",
&mut invocations,
) {
end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure).await;
end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await;
return Ok(failed_preflight_outcome(
headers,
reason,
@@ -759,37 +758,34 @@ impl ChainRunner {
}
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;
}
let invalid = validate_diagnostics(
&decision.reason,
&decision.reason_code,
&decision.findings,
&decision.metadata,
);
if let Err(reason) = invalid {
if let Some(reason) = collect_preflight_failure(&entry, reason, &mut invocations) {
end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await;
return Ok(failed_preflight_outcome(
headers,
reason,
findings,
metadata,
invocations,
));
}
continue;
}
let reason_code = decision.reason_code;
let decision_findings = decision.findings;
let decision_metadata = decision.metadata;
match decision.action {
Some(http_response_preflight_decision::Action::Skip(_skip)) => {
collect_preflight_diagnostics(
&entry,
skip.findings,
skip.metadata,
decision_findings,
decision_metadata,
&mut findings,
&mut metadata,
);
@@ -802,7 +798,7 @@ impl ChainRunner {
output_size: None,
failed: false,
stage_disabled: false,
reason_code: (!skip.reason_code.is_empty()).then_some(skip.reason_code),
reason_code: (!reason_code.is_empty()).then_some(reason_code),
failure_category: None,
});
let mut skipped = HttpResponseStage {
@@ -813,11 +809,9 @@ impl ChainRunner {
declared_trailer_names: BTreeSet::new(),
whole_body: Vec::new(),
};
skipped
.end(HttpResponseSessionEndReason::StageSkipped)
.await;
skipped.end(MiddlewareSessionEndReason::StageSkipped).await;
}
Some(http_response_preflight_decision::Decision::Inspect(inspect)) => {
Some(http_response_preflight_decision::Action::Inspect(inspect)) => {
let mode = match validate_inspect(
&entry,
&inspect,
@@ -832,7 +826,7 @@ impl ChainRunner {
{
end_stages(
&mut stages,
HttpResponseSessionEndReason::MiddlewareFailure,
MiddlewareSessionEndReason::MiddlewareFailure,
)
.await;
return Ok(failed_preflight_outcome(
@@ -862,7 +856,7 @@ impl ChainRunner {
{
end_stages(
&mut stages,
HttpResponseSessionEndReason::MiddlewareFailure,
MiddlewareSessionEndReason::MiddlewareFailure,
)
.await;
return Ok(failed_preflight_outcome(
@@ -888,8 +882,8 @@ impl ChainRunner {
declared_trailer_names.extend(declared.iter().cloned());
collect_preflight_diagnostics(
&entry,
inspect.findings,
inspect.metadata,
decision_findings,
decision_metadata,
&mut findings,
&mut metadata,
);
@@ -906,7 +900,7 @@ impl ChainRunner {
output_size: None,
failed: false,
stage_disabled: false,
reason_code: None,
reason_code: (!reason_code.is_empty()).then_some(reason_code),
failure_category: None,
});
stages.push(HttpResponseStage {
@@ -924,7 +918,7 @@ impl ChainRunner {
"invalid_preflight_decision",
&mut invocations,
) {
end_stages(&mut stages, HttpResponseSessionEndReason::MiddlewareFailure)
end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure)
.await;
return Ok(failed_preflight_outcome(
headers,
@@ -1000,12 +994,17 @@ fn validate_body_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 {})) => {
validate_diagnostics(
&body.reason,
&body.reason_code,
&body.findings,
&body.metadata,
)?;
match body.action {
Some(http_response_body_result::Action::PassThrough(HttpResponseBodyPassThrough {})) => {
Ok(BodyDecision::PassThrough(body.findings, body.metadata))
}
Some(http_response_body_result::Decision::Transform(transform)) => {
Some(http_response_body_result::Action::Transform(transform)) => {
let Some(http_response_body_transform::Replacement::Data(replacement)) =
transform.replacement
else {
@@ -1031,8 +1030,6 @@ fn validate_inspect(
declared_body_length: Option<u64>,
connection_nominated_headers: &[String],
) -> Result<StageMode, String> {
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,
@@ -1321,11 +1318,12 @@ fn body_event(sequence: u64, data: Vec<u8>) -> HttpResponseEvent {
}
}
fn session_end_event(reason: HttpResponseSessionEndReason) -> HttpResponseEvent {
fn session_end_event(reason: MiddlewareSessionEndReason) -> HttpResponseEvent {
HttpResponseEvent {
event: Some(http_response_event::Event::SessionEnd(
HttpResponseSessionEnd {
MiddlewareSessionEnd {
reason: reason as i32,
protocol_error: None,
},
)),
}
@@ -1518,7 +1516,7 @@ fn response_session_capacity_exhausted(
}
}
async fn end_stages(stages: &mut [HttpResponseStage], reason: HttpResponseSessionEndReason) {
async fn end_stages(stages: &mut [HttpResponseStage], reason: MiddlewareSessionEndReason) {
for stage in stages {
stage.end(reason).await;
}
@@ -1629,8 +1627,8 @@ mod tests {
result: Some(
http_response_event_result::Result::PreflightDecision(
HttpResponsePreflightDecision {
decision: Some(
http_response_preflight_decision::Decision::Inspect(
action: Some(
http_response_preflight_decision::Action::Inspect(
HttpResponsePreflightInspect {
body_mode:
HttpResponseBodyMode::HeadersOnly as i32,
@@ -1642,6 +1640,7 @@ mod tests {
},
),
),
..Default::default()
},
),
),
@@ -1741,24 +1740,21 @@ mod tests {
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()
},
action: Some(
http_response_preflight_decision::Action::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()
},
),
),
@@ -1796,16 +1792,16 @@ mod tests {
result: Some(
http_response_event_result::Result::PreflightDecision(
HttpResponsePreflightDecision {
decision: Some(
http_response_preflight_decision::Decision::Inspect(
action: Some(
http_response_preflight_decision::Action::Inspect(
HttpResponsePreflightInspect {
body_mode: body_mode as i32,
header_mutations,
declared_trailer_names,
..Default::default()
},
),
),
..Default::default()
},
),
),
@@ -1843,17 +1839,15 @@ mod tests {
} else {
body.sequence
},
decision: Some(
http_response_body_result::Decision::Transform(
HttpResponseBodyTransform {
replacement: Some(
http_response_body_transform::Replacement::Data(
replacement,
),
action: Some(http_response_body_result::Action::Transform(
HttpResponseBodyTransform {
replacement: Some(
http_response_body_transform::Replacement::Data(
replacement,
),
},
),
),
),
},
)),
..Default::default()
},
)),
@@ -3544,13 +3544,13 @@ where
if committed {
if let Err(error) = client.write_all(&streaming_head).await {
session
.end(openshell_core::proto::HttpResponseSessionEndReason::ClientDisconnect)
.end(openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect)
.await;
return Err(error).into_diagnostic();
}
if let Err(error) = client.flush().await {
session
.end(openshell_core::proto::HttpResponseSessionEndReason::ClientDisconnect)
.end(openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect)
.await;
return Err(error).into_diagnostic();
}
@@ -3578,19 +3578,19 @@ where
.generation_guard
.is_some_and(PolicyGenerationGuard::is_stale)
{
openshell_core::proto::HttpResponseSessionEndReason::PolicyReload
openshell_core::proto::MiddlewareSessionEndReason::PolicyReload
} else if error
.to_string()
.starts_with("HTTP response client write failed:")
{
openshell_core::proto::HttpResponseSessionEndReason::ClientDisconnect
openshell_core::proto::MiddlewareSessionEndReason::PeerDisconnect
} else if error
.to_string()
.starts_with("HTTP response middleware failure:")
{
openshell_core::proto::HttpResponseSessionEndReason::MiddlewareFailure
openshell_core::proto::MiddlewareSessionEndReason::MiddlewareFailure
} else {
openshell_core::proto::HttpResponseSessionEndReason::UpstreamError
openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure
};
session.end(end_reason).await;
if committed {
@@ -3624,7 +3624,7 @@ where
&& let Err(error) = guard.ensure_current()
{
session
.end(openshell_core::proto::HttpResponseSessionEndReason::PolicyReload)
.end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload)
.await;
return Err(error);
}
@@ -4869,16 +4869,16 @@ mod tests {
result: Some(
http_response_event_result::Result::PreflightDecision(
HttpResponsePreflightDecision {
decision: Some(
http_response_preflight_decision::Decision::Inspect(
action: Some(
http_response_preflight_decision::Action::Inspect(
HttpResponsePreflightInspect {
body_mode: body_mode as i32,
header_mutations,
declared_trailer_names,
..Default::default()
},
),
),
..Default::default()
},
),
),
@@ -4913,17 +4913,15 @@ mod tests {
} else {
body.sequence
},
decision: Some(
http_response_body_result::Decision::Transform(
HttpResponseBodyTransform {
replacement: Some(
http_response_body_transform::Replacement::Data(
replacement,
),
action: Some(http_response_body_result::Action::Transform(
HttpResponseBodyTransform {
replacement: Some(
http_response_body_transform::Replacement::Data(
replacement,
),
},
),
),
),
},
)),
..Default::default()
},
)),
+11 -4
View File
@@ -23,8 +23,15 @@ For each inspected HTTP request, the supervisor:
2. Selects middleware whose host selectors match the admitted destination.
3. Buffers the request body using the largest body limit in the selected chain.
4. Runs matching middleware by ascending `order`. Policy validation rejects duplicate order values.
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.
5. Opens `HttpRequestPreCredentials.Evaluate` for the stage. The stage first returns `DENY`, `ALLOW` with `SKIP`, or `ALLOW` with `INSPECT`.
6. For `INSPECT`, applies request-specific restrictions and the selected `HEADERS_ONLY`, `WHOLE_BODY_BYTES`, or `STREAM_BYTES` mode. Whole-body stages receive exactly one body unit, including present empty data for an empty request. Streaming stages receive ordered units of at most 64 KiB and acknowledge each with `PASS_THROUGH`, `TRANSFORM`, or `DENY` before OpenShell sends the next unit.
7. Sends `body_end` after the last inspected unit. OpenShell makes a best-effort `session_end` notification for every opened stream; neither notification has a result.
8. 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.
9. Applies allowed transformations, injects provider credentials, and forwards the request.
Request `decision` values authorize the operation. Request `action` values control processing: preflight uses `SKIP` or `INSPECT`, while body results use `PASS_THROUGH` or `TRANSFORM`. A preflight or body `DENY` has no action. This separation leaves the processing action extensible without overloading authorization.
The current HTTP/1 relay retains the complete request before opening the stage stream, so `STREAM_BYTES` provides bounded, lockstep middleware interactions but does not yet remove the relay's whole-request buffer. OpenShell does not contact the upstream or inject credentials until every selected request stage allows the request. This preserves body-time denial and prevents signed or transformed data from reaching the upstream prematurely.
### HTTP Response Flow
@@ -79,7 +86,7 @@ Choose an implementation, register it when it runs out of process, then attach i
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 [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-content-guard) implements request, response, and WebSocket bindings in one service. Its response paths demonstrate all three body modes, framing repair, and body-derived trailers.
The [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-content-guard) implements request, response, and WebSocket bindings in one service. Its response paths demonstrate all three body modes, framing repair, and body-derived trailers. The [Git signing example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-git-signing) implements the request stream in whole-body mode and rewrites Git receive-pack pushes before credential injection.
### Register a Middleware Service
@@ -105,7 +112,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 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.
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`. During the request-streaming experiment, the effective binding timeout covers the complete request stage stream. It applies per expected result for response and WebSocket streams; those persistent 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.
@@ -367,7 +367,7 @@ impl ResponseSessionState {
result: Some(http_response_event_result::Result::BodyResult(
HttpResponseBodyResult {
sequence: body.sequence,
decision: Some(http_response_body_result::Decision::Transform(
action: Some(http_response_body_result::Action::Transform(
HttpResponseBodyTransform {
replacement: Some(http_response_body_transform::Replacement::Data(
replacement,
@@ -431,13 +431,12 @@ fn response_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 example".into(),
reason_code: "path_not_selected".into(),
..Default::default()
},
action: Some(http_response_preflight_decision::Action::Skip(
HttpResponsePreflightSkip {},
)),
reason: "path is outside the response example".into(),
reason_code: "path_not_selected".into(),
..Default::default()
},
)),
}
@@ -460,7 +459,7 @@ fn response_preflight_inspect(selected: ResponseMode) -> HttpResponseEventResult
HttpResponseEventResult {
result: Some(http_response_event_result::Result::PreflightDecision(
HttpResponsePreflightDecision {
decision: Some(http_response_preflight_decision::Decision::Inspect(
action: Some(http_response_preflight_decision::Action::Inspect(
HttpResponsePreflightInspect {
body_mode: body_mode as i32,
header_mutations: vec![write_header("x-example-response-mode", mode_name)],
@@ -468,6 +467,7 @@ fn response_preflight_inspect(selected: ResponseMode) -> HttpResponseEventResult
..Default::default()
},
)),
..Default::default()
},
)),
}
@@ -731,8 +731,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
mod tests {
use super::*;
use openshell_core::proto::{
HttpRequestTarget, HttpResponseBodyUnit, HttpResponsePreflight, WebSocketPreflight,
WebSocketSessionEnd, WebSocketSessionStart,
HttpRequestTarget, HttpResponseBodyUnit, HttpResponsePreflight, MiddlewareSessionEnd,
WebSocketPreflight, WebSocketSessionStart,
};
use prost_types::{ListValue, Value};
use std::collections::BTreeMap;
@@ -819,8 +819,7 @@ mod tests {
else {
panic!("expected preflight decision");
};
let Some(http_response_preflight_decision::Decision::Inspect(inspect)) =
decision.decision
let Some(http_response_preflight_decision::Action::Inspect(inspect)) = decision.action
else {
panic!("expected inspect decision");
};
@@ -845,8 +844,7 @@ mod tests {
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 {
let Some(http_response_body_result::Action::Transform(transform)) = body.action else {
panic!("expected body transform");
};
let Some(http_response_body_transform::Replacement::Data(data)) = transform.replacement
@@ -866,7 +864,7 @@ mod tests {
else {
panic!("expected preflight decision");
};
let Some(http_response_preflight_decision::Decision::Inspect(inspect)) = decision.decision
let Some(http_response_preflight_decision::Action::Inspect(inspect)) = decision.action
else {
panic!("expected inspect decision");
};
@@ -894,10 +892,10 @@ mod tests {
else {
panic!("expected preflight decision");
};
let Some(http_response_preflight_decision::Decision::Skip(skip)) = decision.decision else {
let Some(http_response_preflight_decision::Action::Skip(_)) = decision.action else {
panic!("expected skip decision");
};
assert_eq!(skip.reason_code, "path_not_selected");
assert_eq!(decision.reason_code, "path_not_selected");
}
#[tokio::test]
@@ -920,7 +918,7 @@ mod tests {
)),
})),
event(web_socket_session_event::Event::SessionEnd(
WebSocketSessionEnd::default(),
MiddlewareSessionEnd::default(),
)),
]);
let mut results = ContentGuard::websocket_stream(events);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[workspace]
[package]
name = "supervisor-middleware-git-signing"
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 }
prost-types = "0.14"
tempfile = "3"
tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] }
tokio-stream = "0.1"
tonic = { version = "0.14", features = ["transport"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[lints.rust]
unsafe_code = "forbid"
@@ -0,0 +1,72 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->
# Git commit signing supervisor middleware prototype
This example tests whether an operator-run OpenShell supervisor middleware can sign commits without mounting a signing key into the sandbox. It uses the streaming `HttpRequestPreCredentials.Evaluate` API to intercept Git smart-HTTP `git-receive-pack` requests after network policy admission and before provider credential injection. The service selects `WHOLE_BODY_BYTES`, rewrites the pushed commit objects with SSH signatures, and returns a transform action for the single request body unit.
The sandbox sees neither the private key nor the signature operation. Its Git client creates ordinary unsigned commits and pushes over HTTPS.
## What the prototype does
For each direct branch update in a push, the service:
1. Derives a bounded `github.com/<org>/<repo>.git` upstream URL from the policy-admitted request target.
2. Shallow-fetches the upstream default branch and updated branch tips into a temporary bare repository. This supplies bases omitted from normal thin pushes.
3. Decodes the receive-pack command pkt-lines and packfile, then walks only commits that are not already reachable from the fetched upstream refs.
4. Removes any existing commit signature and signs the exact rewritten commit payload with `ssh-keygen -Y sign -n git`.
5. Rewrites parent object IDs, creates a replacement thin pack, and substitutes the new branch-tip object ID in the receive-pack command.
6. Returns the modified body to the supervisor, which forwards it with the sandbox's normal GitHub credential.
The signing key is a service startup argument, not policy-controlled middleware configuration. A sandbox cannot select an arbitrary host file to sign with.
## Build and test
The test constructs a two-commit push, transforms it, sends the replacement request through Git's real `receive-pack --stateless-rpc`, checks the updated branch tip, and verifies both SSH signatures with `git verify-commit`.
```shell
cargo test --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml
```
The example requires `git` and `ssh-keygen` on the host.
## Run the service
Use an SSH key that is also registered as a signing key with the Git forge. GitHub distinguishes signing keys from authentication keys even when the same public key material is used.
```shell
cargo run \
--manifest-path examples/supervisor-middleware-git-signing/Cargo.toml \
-- \
--bind 0.0.0.0:50051 \
--signing-key "$HOME/.ssh/id_ed25519"
```
Register the local service in `gateway.toml`. A container-backed gateway can reach a host service through `host.openshell.internal`; a host-native gateway can use `127.0.0.1`.
```toml
[[openshell.supervisor.middleware]]
name = "local-git-signer"
grpc_endpoint = "http://host.openshell.internal:50051"
max_payload_bytes = 4194304
timeout = "30s"
```
Restart the gateway after adding the static registration, then replace `<org>` and `<repo>` in [policy.yaml](policy.yaml) and create a sandbox with that policy. Keep the endpoint on `fail_closed`: an unsigned push is preferable to reject rather than silently forward when signing fails.
The service skips unrelated GitHub HTTP requests during preflight. It inspects only `POST` requests whose path ends in `/git-receive-pack` and whose content type is `application/x-git-receive-pack-request`. This prototype accepts only validated HTTPS targets on `github.com:443` with a two-segment repository path.
The signer implements `SupervisorMiddleware` for discovery and configuration validation, and serves `HttpRequestPreCredentials` alongside it. It deliberately returns `UNIMPLEMENTED` from the legacy unary `EvaluateHttpRequest` method. This makes the example fail if OpenShell silently falls back to the old request API.
## Prototype limits
- The experiment still retains the admitted request body in the relay before opening middleware streams. The new API streams between OpenShell and the service, but it does not yet remove the relay's 4 MiB request buffer. Both the incoming push and expanded replacement pack must fit that limit.
- The implementation supports SHA-1 repositories and direct `refs/heads/*` updates. It rejects SHA-256 repositories, annotated-tag pushes, push certificates, and other ref types.
- Each push shallow-fetches upstream objects before signing. The host must be able to reach the repository, and private repositories need a non-interactive Git credential helper available to the local middleware user. A production design should use a bounded object cache and explicit credential plumbing.
- The service shells out to the host's `git` and `ssh-keygen`; it needs resource limits, concurrency limits, timeouts, and hardened subprocess execution before production use.
- Rewriting commit object IDs means the sandbox's local branch still points to the unsigned commit after a successful push. A subsequent fetch updates the remote-tracking ref, but the local branch must be reconciled with the rewritten history. This is the largest workflow issue for transparent push-time signing.
- The example has unit-level protocol coverage, not a live GitHub push. Test against a disposable repository before using a real signing key.
These constraints make the approach viable as a proof of concept, but not yet transparent enough for general production pushes. A first-class commit-signing operation invoked before Git creates the final local object would avoid the local/remote object-ID split while still keeping the private key outside the sandbox.
@@ -0,0 +1,48 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
version: 1
filesystem_policy:
include_workdir: true
read_only: [/usr, /lib, /proc, /dev/urandom, /etc]
read_write: [/sandbox, /tmp, /dev/null]
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox
network_middlewares:
sign-git-commits:
name: Sign outgoing Git commits
middleware: local-git-signer
order: 10
config: {}
on_error: fail_closed
endpoints:
include: [github.com]
network_policies:
github_git:
name: github-git
endpoints:
- host: github.com
port: 443
protocol: rest
enforcement: enforce
rules:
- allow:
method: GET
path: "/<org>/<repo>.git/info/refs*"
- allow:
method: POST
path: "/<org>/<repo>.git/git-upload-pack"
- allow:
method: POST
path: "/<org>/<repo>.git/git-receive-pack"
binaries:
- { path: /usr/bin/git }
- { path: /usr/lib/git-core/git-remote-http }
@@ -0,0 +1,475 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
mod signer;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Parser;
use openshell_core::middleware::{HttpRequestResultStream, WebSocketResponseStream};
use openshell_core::proto::middleware::v1::http_request_pre_credentials_server::{
HttpRequestPreCredentials, HttpRequestPreCredentialsServer,
};
use openshell_core::proto::middleware::v1::supervisor_middleware_server::{
SupervisorMiddleware, SupervisorMiddlewareServer,
};
use openshell_core::proto::{
Decision, Finding, HttpRequestBodyMode, HttpRequestBodyResult, HttpRequestBodyTransform,
HttpRequestEvent, HttpRequestEventResult, HttpRequestPreflight, HttpRequestPreflightInspect,
HttpRequestPreflightResult, HttpRequestPreflightSkip, HttpRequestResult, MiddlewareBinding,
MiddlewareManifest, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase,
ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, http_request_body_result,
http_request_body_transform, http_request_body_unit, http_request_event,
http_request_event_result, http_request_preflight_result,
};
use tokio_stream::StreamExt as _;
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::Server;
use tonic::{Request, Response, Status};
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use crate::signer::GitSigner;
const MANIFEST_NAME: &str = "example/git-commit-signing";
const OPERATION: SupervisorMiddlewareOperation = SupervisorMiddlewareOperation::HttpRequest;
const PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials;
const MAX_BODY_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Parser)]
#[command(about = "Sign commits in Git smart-HTTP pushes outside an OpenShell sandbox")]
struct Cli {
/// Address on which to serve plaintext gRPC.
#[arg(long, default_value = "127.0.0.1:50051")]
bind: SocketAddr,
/// Local SSH private key used by ssh-keygen. This path is never sent to the sandbox.
#[arg(long)]
signing_key: PathBuf,
}
#[derive(Clone)]
struct GitSigningMiddleware {
signer: Arc<GitSigner>,
}
impl GitSigningMiddleware {
fn new(signing_key: PathBuf) -> Result<Self, String> {
Ok(Self {
signer: Arc::new(GitSigner::new(signing_key)?),
})
}
fn request_stream(
&self,
mut events: tonic::Streaming<HttpRequestEvent>,
) -> HttpRequestResultStream {
let signer = Arc::clone(&self.signer);
let (results_tx, results_rx) = tokio::sync::mpsc::channel(1);
tokio::spawn(async move {
let mut selected = None;
let mut body_complete = false;
while let Some(event) = events.next().await {
let event = match event {
Ok(event) => event,
Err(error) => {
let _ = results_tx.send(Err(error)).await;
break;
}
};
let result = match event.event {
Some(http_request_event::Event::Preflight(preflight))
if selected.is_none() && !body_complete =>
{
match select_request(&preflight) {
Ok(None) => {
selected = Some(Selection::Skipped);
Ok(Some(preflight_skip()))
}
Ok(Some(selection)) => {
selected = Some(selection);
Ok(Some(preflight_whole_body()))
}
Err(status) => Err(status),
}
}
Some(http_request_event::Event::Body(body)) => {
let Some(Selection::Sign {
upstream_url,
request_id,
}) = selected.as_ref()
else {
return send_stream_error(
&results_tx,
Status::failed_precondition("unexpected request body event"),
)
.await;
};
if body_complete || body.sequence != 1 {
Err(Status::invalid_argument(
"whole-body signing expects sequence 1 exactly once",
))
} else {
let Some(http_request_body_unit::Payload::Data(data)) = body.payload
else {
return send_stream_error(
&results_tx,
Status::invalid_argument("request body data is required"),
)
.await;
};
body_complete = true;
sign_body(
Arc::clone(&signer),
data,
upstream_url.clone(),
request_id.clone(),
body.sequence,
)
.await
.map(Some)
}
}
Some(http_request_event::Event::BodyEnd(end))
if matches!(selected, Some(Selection::Sign { .. }))
&& body_complete
&& end.final_sequence == 1 =>
{
Ok(None)
}
Some(http_request_event::Event::SessionEnd(_)) if selected.is_some() => break,
_ => Err(Status::failed_precondition(
"invalid Git signing request stream lifecycle",
)),
};
match result {
Ok(Some(result)) => {
if results_tx.send(Ok(result)).await.is_err() {
break;
}
}
Ok(None) => {}
Err(error) => {
let _ = results_tx.send(Err(error)).await;
break;
}
}
}
});
Box::pin(ReceiverStream::new(results_rx))
}
}
enum Selection {
Skipped,
Sign {
upstream_url: String,
request_id: String,
},
}
#[tonic::async_trait]
impl SupervisorMiddleware for GitSigningMiddleware {
type EvaluateWebSocketSessionStream = WebSocketResponseStream;
async fn describe(
&self,
_request: Request<()>,
) -> Result<Response<MiddlewareManifest>, Status> {
Ok(Response::new(MiddlewareManifest {
name: MANIFEST_NAME.into(),
service_version: env!("CARGO_PKG_VERSION").into(),
bindings: vec![MiddlewareBinding {
operation: OPERATION as i32,
phase: PHASE as i32,
max_payload_bytes: MAX_BODY_BYTES,
timeout: "30s".into(),
}],
expected_audience: String::new(),
}))
}
async fn validate_config(
&self,
request: Request<ValidateConfigRequest>,
) -> Result<Response<ValidateConfigResponse>, Status> {
let request = request.into_inner();
let unknown = request
.config
.as_ref()
.and_then(|config| config.fields.keys().next())
.cloned();
Ok(Response::new(match unknown {
None => ValidateConfigResponse {
valid: true,
reason: String::new(),
},
Some(field) => ValidateConfigResponse {
valid: false,
reason: format!("unsupported config field '{field}'"),
},
}))
}
async fn evaluate_http_request(
&self,
_request: Request<openshell_core::proto::HttpRequestEvaluation>,
) -> Result<Response<HttpRequestResult>, Status> {
Err(Status::unimplemented(
"git signer requires HttpRequestPreCredentials.Evaluate",
))
}
async fn evaluate_web_socket_session(
&self,
_request: Request<tonic::Streaming<WebSocketSessionEvent>>,
) -> Result<Response<Self::EvaluateWebSocketSessionStream>, Status> {
Err(Status::unimplemented(
"WebSocket middleware is not supported",
))
}
}
#[tonic::async_trait]
impl HttpRequestPreCredentials for GitSigningMiddleware {
type EvaluateStream = HttpRequestResultStream;
async fn evaluate(
&self,
request: Request<tonic::Streaming<HttpRequestEvent>>,
) -> Result<Response<Self::EvaluateStream>, Status> {
Ok(Response::new(self.request_stream(request.into_inner())))
}
}
fn select_request(preflight: &HttpRequestPreflight) -> Result<Option<Selection>, Status> {
if !is_receive_pack_request(preflight) {
return Ok(None);
}
let target = preflight
.target
.as_ref()
.ok_or_else(|| Status::invalid_argument("Git push request has no target"))?;
Ok(Some(Selection::Sign {
upstream_url: github_upstream_url(target)?,
request_id: preflight
.context
.as_ref()
.map(|context| context.request_id.clone())
.unwrap_or_default(),
}))
}
fn preflight_skip() -> HttpRequestEventResult {
HttpRequestEventResult {
result: Some(http_request_event_result::Result::PreflightResult(
HttpRequestPreflightResult {
decision: Decision::Allow as i32,
action: Some(http_request_preflight_result::Action::Skip(
HttpRequestPreflightSkip {},
)),
reason_code: "not_git_receive_pack".into(),
..Default::default()
},
)),
}
}
fn preflight_whole_body() -> HttpRequestEventResult {
HttpRequestEventResult {
result: Some(http_request_event_result::Result::PreflightResult(
HttpRequestPreflightResult {
decision: Decision::Allow as i32,
action: Some(http_request_preflight_result::Action::Inspect(
HttpRequestPreflightInspect {
body_mode: HttpRequestBodyMode::WholeBodyBytes as i32,
header_mutations: Vec::new(),
},
)),
reason_code: "git_receive_pack_selected".into(),
..Default::default()
},
)),
}
}
fn body_transform(sequence: u64, body: Vec<u8>, signed_commits: u32) -> HttpRequestEventResult {
HttpRequestEventResult {
result: Some(http_request_event_result::Result::BodyResult(
HttpRequestBodyResult {
sequence,
decision: Decision::Allow as i32,
action: Some(http_request_body_result::Action::Transform(
HttpRequestBodyTransform {
replacement: Some(http_request_body_transform::Replacement::Data(body)),
},
)),
findings: vec![Finding {
r#type: "git.commit.signed".into(),
label: "outgoing Git commits signed".into(),
count: signed_commits,
confidence: "high".into(),
severity: "informational".into(),
}],
metadata: HashMap::from([(
"signed_commit_count".into(),
signed_commits.to_string(),
)]),
reason_code: "git_commits_signed".into(),
..Default::default()
},
)),
}
}
async fn sign_body(
signer: Arc<GitSigner>,
data: Vec<u8>,
upstream_url: String,
request_id: String,
sequence: u64,
) -> Result<HttpRequestEventResult, Status> {
let log_upstream = upstream_url.clone();
let log_request_id = request_id.clone();
let signed =
tokio::task::spawn_blocking(move || signer.sign_receive_pack(&data, Some(&upstream_url)))
.await
.map_err(|_| Status::internal("git signing worker failed"))?
.map_err(|error| {
warn!(
request_id = log_request_id,
upstream = %log_upstream,
error = %error,
"outgoing Git push could not be signed"
);
Status::failed_precondition(error.public_message())
})?;
info!(
request_id,
upstream = %log_upstream,
signed_commits = signed.signed_commits,
"signed outgoing Git push"
);
Ok(body_transform(sequence, signed.body, signed.signed_commits))
}
async fn send_stream_error(
sender: &tokio::sync::mpsc::Sender<Result<HttpRequestEventResult, Status>>,
status: Status,
) {
let _ = sender.send(Err(status)).await;
}
fn github_upstream_url(
target: &openshell_core::proto::HttpRequestTarget,
) -> Result<String, Status> {
if target.scheme != "https" || target.host != "github.com" || target.port != 443 {
return Err(Status::invalid_argument(
"prototype supports HTTPS pushes to github.com only",
));
}
let repository_path = target
.path
.strip_suffix("/git-receive-pack")
.ok_or_else(|| Status::invalid_argument("invalid Git receive-pack path"))?;
let segments = repository_path
.strip_prefix('/')
.and_then(|path| path.strip_suffix(".git"))
.map(|path| path.split('/').collect::<Vec<_>>())
.ok_or_else(|| Status::invalid_argument("invalid GitHub repository path"))?;
if segments.len() != 2
|| segments.iter().any(|segment| {
segment.is_empty()
|| !segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
})
{
return Err(Status::invalid_argument("invalid GitHub repository path"));
}
Ok(format!("https://github.com{repository_path}"))
}
fn is_receive_pack_request(request: &HttpRequestPreflight) -> bool {
let Some(target) = request.target.as_ref() else {
return false;
};
target.method == "POST"
&& target.path.ends_with("/git-receive-pack")
&& request.headers.iter().any(|header| {
header.name.eq_ignore_ascii_case("content-type")
&& header
.value
.split(';')
.next()
.is_some_and(|value| value.trim() == "application/x-git-receive-pack-request")
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
let middleware = GitSigningMiddleware::new(cli.signing_key)
.map_err(|error| format!("invalid signing configuration: {error}"))?;
info!(bind = %cli.bind, "starting Git commit signing middleware");
Server::builder()
.add_service(SupervisorMiddlewareServer::new(middleware.clone()))
.add_service(HttpRequestPreCredentialsServer::new(middleware))
.serve(cli.bind)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use openshell_core::proto::{HttpHeader, HttpRequestTarget};
#[test]
fn recognizes_git_receive_pack_only() {
let request = HttpRequestPreflight {
target: Some(HttpRequestTarget {
method: "POST".into(),
path: "/NVIDIA/OpenShell.git/git-receive-pack".into(),
..Default::default()
}),
headers: vec![HttpHeader {
name: "content-type".into(),
value: "application/x-git-receive-pack-request".into(),
}],
..Default::default()
};
assert!(is_receive_pack_request(&request));
let mut fetch = request;
fetch.target.as_mut().unwrap().path = "/NVIDIA/OpenShell.git/git-upload-pack".into();
assert!(!is_receive_pack_request(&fetch));
}
#[test]
fn derives_a_bounded_github_upstream_url() {
let target = openshell_core::proto::HttpRequestTarget {
scheme: "https".into(),
host: "github.com".into(),
port: 443,
path: "/NVIDIA/OpenShell.git/git-receive-pack".into(),
..Default::default()
};
assert_eq!(
github_upstream_url(&target).unwrap(),
"https://github.com/NVIDIA/OpenShell.git"
);
let mut traversal = target;
traversal.path = "/NVIDIA/../OpenShell.git/git-receive-pack".into();
assert!(github_upstream_url(&traversal).is_err());
}
}
@@ -0,0 +1,713 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;
const SHA1_HEX_LEN: usize = 40;
const ZERO_SHA1: &str = "0000000000000000000000000000000000000000";
pub struct GitSigner {
signing_key: PathBuf,
}
pub struct SignedPush {
pub body: Vec<u8>,
pub signed_commits: u32,
}
#[derive(Debug)]
pub struct SignError {
message: String,
}
impl SignError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn public_message(&self) -> &'static str {
"outgoing Git push could not be signed"
}
}
impl fmt::Display for SignError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for SignError {}
impl GitSigner {
pub fn new(signing_key: PathBuf) -> Result<Self, String> {
if !signing_key.is_file() {
return Err("the signing key must name a readable file".into());
}
Ok(Self { signing_key })
}
pub fn sign_receive_pack(
&self,
body: &[u8],
upstream_url: Option<&str>,
) -> Result<SignedPush, SignError> {
let mut parsed = ReceivePackRequest::parse(body)?;
if parsed.updates.is_empty() {
return Err(SignError::new(
"receive-pack request contains no ref updates",
));
}
let workspace = TempDir::new().map_err(|error| SignError::new(error.to_string()))?;
let repo = workspace.path().join("objects.git");
run_git(None, &["init", "--bare", repo_string(&repo)?], None)?;
if let Some(upstream_url) = upstream_url {
hydrate_upstream(&repo, upstream_url, &parsed.updates)?;
}
run_git(
Some(&repo),
&["index-pack", "--stdin", "--fix-thin"],
Some(parsed.pack),
)?;
let mut commit_ids = HashSet::new();
for update in &parsed.updates {
if update.new_oid == ZERO_SHA1 {
continue;
}
let output = run_git(
Some(&repo),
&["rev-list", &update.new_oid, "--not", "--all"],
None,
)?;
let output = String::from_utf8(output)
.map_err(|_| SignError::new("git returned a non-UTF-8 commit list"))?;
commit_ids.extend(output.lines().map(str::to_string));
}
let mut rewriter = CommitRewriter {
repo: &repo,
signing_key: &self.signing_key,
commit_ids,
rewritten: HashMap::new(),
active: HashSet::new(),
signed_count: 0,
workspace: workspace.path(),
};
for update in &mut parsed.updates {
if update.new_oid == ZERO_SHA1 {
continue;
}
if !update.ref_name.starts_with("refs/heads/") {
return Err(SignError::new(
"prototype supports direct branch updates only",
));
}
if !rewriter.commit_ids.contains(&update.new_oid) {
return Err(SignError::new(
"branch tip commit is not self-contained in the push pack",
));
}
update.new_oid = rewriter.rewrite(&update.new_oid)?;
}
if rewriter.signed_count == 0 {
return Err(SignError::new(
"receive-pack request contains no commits to sign",
));
}
let base_oids = list_ref_oids(&repo, "refs/middleware")?;
let mut revisions = parsed
.updates
.iter()
.filter(|update| update.new_oid != ZERO_SHA1)
.map(|update| update.new_oid.clone())
.collect::<Vec<_>>();
revisions.extend(base_oids.into_iter().map(|oid| format!("^{oid}")));
let revision_input = revisions.join("\n") + "\n";
let replacement_pack = run_git(
Some(&repo),
&["pack-objects", "--stdout", "--revs", "--thin"],
Some(revision_input.as_bytes()),
)?;
let mut result = parsed.prefix.to_vec();
for update in &parsed.updates {
result[update.new_oid_range.clone()].copy_from_slice(update.new_oid.as_bytes());
}
result.extend_from_slice(&replacement_pack);
Ok(SignedPush {
body: result,
signed_commits: rewriter.signed_count,
})
}
}
struct ReceivePackRequest<'a> {
prefix: &'a [u8],
pack: &'a [u8],
updates: Vec<RefUpdate>,
}
struct RefUpdate {
old_oid: String,
new_oid: String,
ref_name: String,
new_oid_range: std::ops::Range<usize>,
}
impl<'a> ReceivePackRequest<'a> {
fn parse(body: &'a [u8]) -> Result<Self, SignError> {
let mut updates = Vec::new();
let mut offset = 0;
let mut command_section = true;
let pack_offset = loop {
if !command_section && body[offset..].starts_with(b"PACK") {
break offset;
}
if offset + 4 > body.len() {
return Err(SignError::new("receive-pack request has no packfile"));
}
let length = parse_pkt_length(&body[offset..offset + 4])?;
if length == 0 {
offset += 4;
command_section = false;
continue;
}
if length < 4 || offset + length > body.len() {
return Err(SignError::new("invalid receive-pack pkt-line length"));
}
if !command_section {
// Push options, when negotiated, are pkt-lines between the
// command flush and the packfile. Preserve them unchanged.
offset += length;
continue;
}
let payload_start = offset + 4;
let payload = &body[payload_start..offset + length];
let command = payload.split(|byte| *byte == 0).next().unwrap_or(payload);
let command = command.strip_suffix(b"\n").unwrap_or(command);
let first_space = command
.iter()
.position(|byte| *byte == b' ')
.ok_or_else(|| SignError::new("invalid receive-pack ref command"))?;
let second_space = command[first_space + 1..]
.iter()
.position(|byte| *byte == b' ')
.map(|index| first_space + 1 + index)
.ok_or_else(|| SignError::new("invalid receive-pack ref command"))?;
if first_space != SHA1_HEX_LEN || second_space - first_space - 1 != SHA1_HEX_LEN {
return Err(SignError::new(
"prototype supports SHA-1 Git repositories only",
));
}
let new_start = payload_start + first_space + 1;
let old_oid = ascii_oid(&body[payload_start..payload_start + SHA1_HEX_LEN])?;
let new_oid = ascii_oid(&body[new_start..new_start + SHA1_HEX_LEN])?;
let ref_name = std::str::from_utf8(&command[second_space + 1..])
.map_err(|_| SignError::new("receive-pack ref name is not UTF-8"))?
.to_string();
updates.push(RefUpdate {
old_oid,
new_oid,
ref_name,
new_oid_range: new_start..new_start + SHA1_HEX_LEN,
});
offset += length;
};
Ok(Self {
prefix: &body[..pack_offset],
pack: &body[pack_offset..],
updates,
})
}
}
fn hydrate_upstream(
repo: &Path,
upstream_url: &str,
updates: &[RefUpdate],
) -> Result<(), SignError> {
run_git(
Some(repo),
&[
"-c",
"credential.interactive=false",
"fetch",
"--no-tags",
"--depth=1",
upstream_url,
"+HEAD:refs/middleware/upstream-head",
],
None,
)?;
for (index, old_oid) in updates
.iter()
.map(|update| update.old_oid.as_str())
.filter(|oid| *oid != ZERO_SHA1)
.collect::<HashSet<_>>()
.into_iter()
.enumerate()
{
let destination = format!("+{old_oid}:refs/middleware/base-{index}");
run_git(
Some(repo),
&[
"-c",
"credential.interactive=false",
"fetch",
"--no-tags",
"--depth=1",
upstream_url,
&destination,
],
None,
)?;
}
Ok(())
}
fn list_ref_oids(repo: &Path, prefix: &str) -> Result<Vec<String>, SignError> {
let output = run_git(
Some(repo),
&["for-each-ref", "--format=%(objectname)", prefix],
None,
)?;
let output = String::from_utf8(output)
.map_err(|_| SignError::new("git returned a non-UTF-8 ref list"))?;
Ok(output.lines().map(str::to_string).collect())
}
fn parse_pkt_length(bytes: &[u8]) -> Result<usize, SignError> {
let text =
std::str::from_utf8(bytes).map_err(|_| SignError::new("pkt-line length is not ASCII"))?;
usize::from_str_radix(text, 16).map_err(|_| SignError::new("invalid pkt-line length"))
}
fn ascii_oid(bytes: &[u8]) -> Result<String, SignError> {
if bytes.len() != SHA1_HEX_LEN || !bytes.iter().all(u8::is_ascii_hexdigit) {
return Err(SignError::new("invalid SHA-1 object id"));
}
String::from_utf8(bytes.to_vec()).map_err(|_| SignError::new("invalid SHA-1 object id"))
}
struct CommitRewriter<'a> {
repo: &'a Path,
signing_key: &'a Path,
commit_ids: HashSet<String>,
rewritten: HashMap<String, String>,
active: HashSet<String>,
signed_count: u32,
workspace: &'a Path,
}
impl CommitRewriter<'_> {
fn rewrite(&mut self, oid: &str) -> Result<String, SignError> {
if let Some(rewritten) = self.rewritten.get(oid) {
return Ok(rewritten.clone());
}
if !self.commit_ids.contains(oid) {
return Ok(oid.to_string());
}
if !self.active.insert(oid.to_string()) {
return Err(SignError::new("commit graph contains a cycle"));
}
let raw = run_git(Some(self.repo), &["cat-file", "commit", oid], None)?;
let parsed = ParsedCommit::parse(&raw)?;
let mut parents = Vec::with_capacity(parsed.parents.len());
for parent in &parsed.parents {
parents.push(self.rewrite(parent)?);
}
let unsigned = parsed.unsigned_with_parents(&parents);
let signature = self.sign_payload(oid, &unsigned)?;
let signed = insert_signature(&unsigned, &signature)?;
let new_oid = String::from_utf8(run_git(
Some(self.repo),
&["hash-object", "-t", "commit", "-w", "--stdin"],
Some(&signed),
)?)
.map_err(|_| SignError::new("git returned a non-UTF-8 object id"))?
.trim()
.to_string();
self.active.remove(oid);
self.rewritten.insert(oid.to_string(), new_oid.clone());
self.signed_count = self.signed_count.saturating_add(1);
Ok(new_oid)
}
fn sign_payload(&self, oid: &str, payload: &[u8]) -> Result<Vec<u8>, SignError> {
let payload_path = self.workspace.join(format!("commit-{oid}"));
fs::write(&payload_path, payload).map_err(|error| SignError::new(error.to_string()))?;
let status = Command::new("ssh-keygen")
.args(["-Y", "sign", "-n", "git", "-f"])
.arg(self.signing_key)
.arg(&payload_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.status()
.map_err(|error| SignError::new(format!("could not run ssh-keygen: {error}")))?;
if !status.success() {
return Err(SignError::new("ssh-keygen refused to sign the commit"));
}
// ssh-keygen appends `.sig` to the input path.
let signature_path = PathBuf::from(format!("{}.sig", payload_path.display()));
fs::read(signature_path).map_err(|error| SignError::new(error.to_string()))
}
}
struct ParsedCommit {
headers: Vec<Header>,
parents: Vec<String>,
message: Vec<u8>,
}
struct Header {
name: Vec<u8>,
block: Vec<u8>,
}
impl ParsedCommit {
fn parse(raw: &[u8]) -> Result<Self, SignError> {
let separator = raw
.windows(2)
.position(|window| window == b"\n\n")
.ok_or_else(|| SignError::new("commit object has no header separator"))?;
let header_bytes = &raw[..separator];
let mut headers: Vec<Header> = Vec::new();
for line in header_bytes.split(|byte| *byte == b'\n') {
if line.starts_with(b" ") {
let previous = headers
.last_mut()
.ok_or_else(|| SignError::new("commit starts with a continuation header"))?;
previous.block.push(b'\n');
previous.block.extend_from_slice(line);
continue;
}
let name_end = line
.iter()
.position(|byte| *byte == b' ')
.ok_or_else(|| SignError::new("invalid commit header"))?;
headers.push(Header {
name: line[..name_end].to_vec(),
block: line.to_vec(),
});
}
let parents = headers
.iter()
.filter(|header| header.name == b"parent")
.map(|header| ascii_oid(&header.block[b"parent ".len()..]))
.collect::<Result<Vec<_>, _>>()?;
if !headers.iter().any(|header| header.name == b"tree") {
return Err(SignError::new("commit object has no tree"));
}
Ok(Self {
headers,
parents,
message: raw[separator + 2..].to_vec(),
})
}
fn unsigned_with_parents(&self, parents: &[String]) -> Vec<u8> {
let mut result = Vec::new();
let mut parent_index = 0;
for header in &self.headers {
if header.name == b"gpgsig" || header.name == b"gpgsig-sha256" {
continue;
}
if header.name == b"parent" {
result.extend_from_slice(b"parent ");
result.extend_from_slice(parents[parent_index].as_bytes());
parent_index += 1;
} else {
result.extend_from_slice(&header.block);
}
result.push(b'\n');
}
result.push(b'\n');
result.extend_from_slice(&self.message);
result
}
}
fn insert_signature(unsigned: &[u8], signature: &[u8]) -> Result<Vec<u8>, SignError> {
let separator = unsigned
.windows(2)
.position(|window| window == b"\n\n")
.ok_or_else(|| SignError::new("unsigned commit has no header separator"))?;
let signature = signature.strip_suffix(b"\n").unwrap_or(signature);
let mut result = Vec::with_capacity(unsigned.len() + signature.len() + 16);
result.extend_from_slice(&unsigned[..separator + 1]);
for (index, line) in signature.split(|byte| *byte == b'\n').enumerate() {
result.extend_from_slice(if index == 0 { b"gpgsig " } else { b" " });
result.extend_from_slice(line);
result.push(b'\n');
}
result.extend_from_slice(&unsigned[separator + 1..]);
Ok(result)
}
fn repo_string(path: &Path) -> Result<&str, SignError> {
path.to_str()
.ok_or_else(|| SignError::new("temporary repository path is not UTF-8"))
}
fn run_git(repo: Option<&Path>, args: &[&str], input: Option<&[u8]>) -> Result<Vec<u8>, SignError> {
let mut command = Command::new("git");
if let Some(repo) = repo {
command.arg("-C").arg(repo);
}
command
.args(args)
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|error| SignError::new(format!("could not run git: {error}")))?;
if let Some(input) = input {
child
.stdin
.take()
.ok_or_else(|| SignError::new("git stdin was unavailable"))?
.write_all(input)
.map_err(|error| SignError::new(error.to_string()))?;
}
let output = child
.wait_with_output()
.map_err(|error| SignError::new(error.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SignError::new(format!(
"git {} failed: {}",
args.first().copied().unwrap_or("command"),
stderr.trim()
)));
}
Ok(output.stdout)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signs_every_commit_and_rewrites_the_branch_tip() {
let fixture = TempDir::new().unwrap();
let source = fixture.path().join("source.git");
run_git(
None,
&["init", "--bare", repo_string(&source).unwrap()],
None,
)
.unwrap();
let blob = object(&source, "blob", b"hello\n");
let tree_line = format!("100644 blob {blob}\tREADME.md\n");
let tree = String::from_utf8(
run_git(Some(&source), &["mktree"], Some(tree_line.as_bytes())).unwrap(),
)
.unwrap()
.trim()
.to_string();
let first = unsigned_commit(&source, &tree, None, "first");
let second = unsigned_commit(&source, &tree, Some(&first), "second");
let objects = format!("{blob}\n{tree}\n{first}\n{second}\n");
let pack = run_git(
Some(&source),
&["pack-objects", "--stdout"],
Some(objects.as_bytes()),
)
.unwrap();
let body = receive_pack_body(&second, &pack);
let key = fixture.path().join("signing-key");
let status = Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
.arg(&key)
.status()
.unwrap();
assert!(status.success());
let signed = GitSigner::new(key.clone())
.unwrap()
.sign_receive_pack(&body, None)
.unwrap();
assert_eq!(signed.signed_commits, 2);
let parsed = ReceivePackRequest::parse(&signed.body).unwrap();
let new_tip = parsed.updates[0].new_oid.clone();
assert_ne!(new_tip, second);
let verify = fixture.path().join("verify.git");
run_git(
None,
&["init", "--bare", repo_string(&verify).unwrap()],
None,
)
.unwrap();
run_git(
None,
&[
"receive-pack",
"--stateless-rpc",
repo_string(&verify).unwrap(),
],
Some(&signed.body),
)
.unwrap();
let received_tip = String::from_utf8(
run_git(Some(&verify), &["rev-parse", "refs/heads/main"], None).unwrap(),
)
.unwrap();
assert_eq!(received_tip.trim(), new_tip);
let tip = run_git(Some(&verify), &["cat-file", "commit", &new_tip], None).unwrap();
assert!(tip.windows(7).any(|window| window == b"gpgsig "));
let tip = ParsedCommit::parse(&tip).unwrap();
assert_eq!(tip.parents.len(), 1);
assert_ne!(tip.parents[0], first);
let parent = run_git(
Some(&verify),
&["cat-file", "commit", &tip.parents[0]],
None,
)
.unwrap();
assert!(parent.windows(7).any(|window| window == b"gpgsig "));
let allowed = fixture.path().join("allowed-signers");
let public_key = fs::read_to_string(key.with_extension("pub")).unwrap();
fs::write(&allowed, format!("agent@example.com {}", public_key.trim())).unwrap();
verify_commit(&verify, &allowed, &new_tip);
verify_commit(&verify, &allowed, &tip.parents[0]);
}
#[test]
fn rejects_non_branch_updates() {
let payload = format!("{ZERO_SHA1} {ZERO_SHA1} refs/tags/v1\n");
let length = payload.len() + 4;
let mut body = format!("{length:04x}{payload}0000").into_bytes();
body.extend_from_slice(b"PACK");
let parsed = ReceivePackRequest::parse(&body).unwrap();
assert_eq!(parsed.updates[0].ref_name, "refs/tags/v1");
}
#[test]
fn resolves_a_thin_pack_from_the_upstream_repository() {
let fixture = TempDir::new().unwrap();
let source = fixture.path().join("source.git");
run_git(
None,
&["init", "--bare", repo_string(&source).unwrap()],
None,
)
.unwrap();
let blob = object(&source, "blob", b"base\n");
let tree_line = format!("100644 blob {blob}\tREADME.md\n");
let tree = String::from_utf8(
run_git(Some(&source), &["mktree"], Some(tree_line.as_bytes())).unwrap(),
)
.unwrap()
.trim()
.to_string();
let base = unsigned_commit(&source, &tree, None, "base");
run_git(
Some(&source),
&["update-ref", "refs/heads/main", &base],
None,
)
.unwrap();
run_git(
Some(&source),
&["symbolic-ref", "HEAD", "refs/heads/main"],
None,
)
.unwrap();
let tip = unsigned_commit(&source, &tree, Some(&base), "tip");
let revisions = format!("{tip}\n^{base}\n");
let pack = run_git(
Some(&source),
&["pack-objects", "--stdout", "--revs", "--thin"],
Some(revisions.as_bytes()),
)
.unwrap();
let body = receive_pack_body(&tip, &pack);
let key = fixture.path().join("signing-key");
assert!(
Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
.arg(&key)
.status()
.unwrap()
.success()
);
let signed = GitSigner::new(key)
.unwrap()
.sign_receive_pack(&body, source.to_str())
.unwrap();
assert_eq!(signed.signed_commits, 1);
}
fn object(repo: &Path, kind: &str, body: &[u8]) -> String {
String::from_utf8(
run_git(
Some(repo),
&["hash-object", "-t", kind, "-w", "--stdin"],
Some(body),
)
.unwrap(),
)
.unwrap()
.trim()
.to_string()
}
fn unsigned_commit(repo: &Path, tree: &str, parent: Option<&str>, subject: &str) -> String {
let parent = parent.map_or(String::new(), |oid| format!("parent {oid}\n"));
let raw = format!(
"tree {tree}\n{parent}author Agent <agent@example.com> 1700000000 +0000\ncommitter Agent <agent@example.com> 1700000000 +0000\n\n{subject}\n"
);
object(repo, "commit", raw.as_bytes())
}
fn receive_pack_body(new_oid: &str, pack: &[u8]) -> Vec<u8> {
let payload = format!(
"{ZERO_SHA1} {new_oid} refs/heads/main\0 report-status side-band-64k object-format=sha1\n"
);
let length = payload.len() + 4;
let mut body = format!("{length:04x}{payload}0000").into_bytes();
body.extend_from_slice(pack);
body
}
fn verify_commit(repo: &Path, allowed: &Path, oid: &str) {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["-c", "gpg.format=ssh", "-c"])
.arg(format!("gpg.ssh.allowedSignersFile={}", allowed.display()))
.args(["verify-commit", oid])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
}
+219 -13
View File
@@ -19,7 +19,9 @@ service SupervisorMiddleware {
rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse);
// EvaluateHttpRequest returns an allow, deny, or mutation decision for one
// buffered HTTP request.
// buffered HTTP request. This compatibility RPC is retained during the
// request-streaming experiment. New operator services implement
// HttpRequestPreCredentials instead.
rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult);
// EvaluateWebSocketSession opens one ordered, phase-specific stream for a
@@ -33,6 +35,21 @@ service SupervisorMiddleware {
returns (stream WebSocketSessionEventResult);
}
// HttpRequestPreCredentials evaluates one ordered request stream for one
// selected middleware stage after network policy admission and before OpenShell
// injects credentials or contacts the upstream.
// The operator registration serves this phase-specific service alongside
// SupervisorMiddleware, which remains responsible for discovery and
// configuration validation.
service HttpRequestPreCredentials {
// Evaluate opens one stage-local stream for one HTTP request. The first event
// is preflight. Body events follow only when selected by the preflight result.
// OpenShell attempts at most one session_end before closing the stream when
// its transport is still writable.
rpc Evaluate(stream HttpRequestEvent)
returns (stream HttpRequestEventResult);
}
// 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.
@@ -135,6 +152,187 @@ message HttpHeader {
string value = 2;
}
// HttpRequestEvent is one ordered event in a stage-local request stream. A
// stream starts with exactly one preflight. An inspecting body stage then
// receives zero or more body units and one body_end. Skip and headers-only
// stages receive neither. OpenShell may finish any opened stream with one
// best-effort session_end.
message HttpRequestEvent {
oneof event {
// Initial request head and sandbox context for this stage.
HttpRequestPreflight preflight = 1;
// One normalized request-body unit selected by OpenShell.
HttpRequestBodyUnit body = 2;
// Notification that OpenShell sent every body unit to this stage.
HttpRequestBodyEnd body_end = 3;
// Best-effort terminal notification for this stage stream.
MiddlewareSessionEnd session_end = 4;
}
}
// HttpRequestEventResult acknowledges request preflight or body events. Results
// must follow event order. Every preflight and body event requires exactly one
// matching result. Body end and session end do not produce results in V1.
message HttpRequestEventResult {
oneof result {
// Result for the stream's initial preflight.
HttpRequestPreflightResult preflight_result = 1;
// Result for the next outstanding body unit.
HttpRequestBodyResult body_result = 2;
}
}
// HttpRequestPreflight exposes the current admitted request head to one stage.
message HttpRequestPreflight {
// Sandbox and request identity available to the supervisor. The encoded
// context is limited to 4 KiB.
RequestContext context = 1;
// Admitted request destination, method, path, and redacted query. The encoded
// target is limited to 32 KiB.
HttpRequestTarget target = 2;
// Current request headers after accepted mutations from earlier stages and
// before credential injection, in wire order. Repeated names remain separate
// entries. Protected credential, routing, framing, and hop-by-hop fields are
// omitted. At most 128 lines and 64 KiB of encoded headers are included.
repeated HttpHeader headers = 3;
// Built-in middleware name or operator-owned registration name.
string middleware_name = 4;
// Validated service-specific policy configuration. The encoded configuration
// is limited to 64 KiB.
google.protobuf.Struct config = 5;
// Effective minimum of the platform, registration, and binding limits. This
// limits the complete input and replacement in WHOLE_BODY_BYTES, and each
// input unit and replacement in STREAM_BYTES.
uint64 max_payload_bytes = 6;
}
// HttpRequestPreflightResult authorizes the request and either declines it or
// selects an inspection mode. Diagnostics apply to every decision and action.
message HttpRequestPreflightResult {
// Required authorization decision. DENY requires action to be unset. ALLOW
// requires exactly one action.
Decision decision = 1;
oneof action {
// Successfully decline this request without invoking on_error.
HttpRequestPreflightSkip skip = 2;
// Inspect the request using the selected body mode and mutations.
HttpRequestPreflightInspect inspect = 3;
}
// Free-form service diagnostic. OpenShell never exposes this to the sandbox
// or security logs. Limited to 4 KiB before discarding.
string reason = 4;
// Optional stable machine-readable deny or diagnostic code. Codes must start
// with a lowercase ASCII letter and contain only lowercase ASCII letters,
// digits, and underscores, with a maximum length of 64 bytes.
string reason_code = 5;
// Audit-safe findings produced during preflight. At most 32 findings of at
// most 4 KiB encoded each are accepted across the complete stage.
repeated Finding findings = 6;
// Non-secret service-defined metadata included in diagnostics. At most 64
// entries and 32 KiB of combined key/value data are accepted per stage.
map<string, string> metadata = 7;
}
// HttpRequestPreflightSkip ends this stage successfully for the current
// request. OpenShell sends no body or body-end events to the stage.
message HttpRequestPreflightSkip {}
// HttpRequestPreflightInspect selects this stage's request inspection mode and
// proposes mutations to the current request head.
message HttpRequestPreflightInspect {
// Required request body mode. UNSPECIFIED is a middleware failure handled
// according to on_error.
HttpRequestBodyMode body_mode = 1;
// Ordered request-header mutations applied atomically before the next stage
// and before credential injection. Credential, routing, framing, and
// hop-by-hop fields remain protected. At most 64 operations, 32 KiB of
// validated name/value data, and 64 KiB encoded are accepted.
repeated HeaderMutation header_mutations = 2;
}
// HttpRequestBodyMode controls which request-body events one stage receives.
enum HttpRequestBodyMode {
// Invalid response value handled according to the policy failure mode.
HTTP_REQUEST_BODY_MODE_UNSPECIFIED = 0;
// Inspect and mutate only the request head. The stage receives no body or
// body-end events.
HTTP_REQUEST_BODY_MODE_HEADERS_ONLY = 1;
// Receive the complete normalized request body as exactly one body unit. The
// whole input and its replacement must fit max_payload_bytes.
HTTP_REQUEST_BODY_MODE_WHOLE_BODY_BYTES = 2;
// Receive zero or more normalized byte units. Each input and replacement must
// fit max_payload_bytes. During the request-streaming experiment, the current
// HTTP/1 relay also retains and limits the complete request body before
// opening this stream.
HTTP_REQUEST_BODY_MODE_STREAM_BYTES = 3;
}
// HttpRequestBodyUnit contains one supervisor-defined logical body unit. Unit
// boundaries have no HTTP transport or application semantic meaning.
message HttpRequestBodyUnit {
// Contiguous and stage-local, starting at 1.
uint64 sequence = 1;
oneof payload {
// Normalized request bytes with no HTTP transfer-framing significance.
// WHOLE_BODY_BYTES uses present empty data for an empty body so middleware
// can replace it with nonempty data. STREAM_BYTES units are at most the
// smaller of 64 KiB and max_payload_bytes.
bytes data = 2;
}
}
// HttpRequestBodyResult authorizes, acknowledges, and finalizes exactly one
// body unit. V1 processes units in lockstep and does not send the next unit to a
// stage until the current result passes validation.
message HttpRequestBodyResult {
// Must exactly match the sequence of the next outstanding body unit. Zero,
// gaps, duplicates, and regressions are invalid.
uint64 sequence = 1;
// Required authorization decision. DENY requires action to be unset. ALLOW
// requires exactly one action.
Decision decision = 2;
oneof action {
// Forward the corresponding input unit without modification.
HttpRequestBodyPassThrough pass_through = 3;
// Replace the complete corresponding input unit.
HttpRequestBodyTransform transform = 4;
}
// Free-form service diagnostic. OpenShell never exposes this to the sandbox
// or security logs. Limited to 4 KiB before discarding.
string reason = 5;
// Optional stable machine-readable deny or diagnostic code. Codes follow
// HttpRequestPreflightResult.reason_code.
string reason_code = 6;
// Audit-safe findings produced for this body unit. The complete stage accepts
// at most 32 findings of at most 4 KiB encoded each.
repeated Finding findings = 7;
// Non-secret service-defined metadata included in diagnostics. The complete
// stage accepts at most 64 entries and 32 KiB of combined key/value data.
map<string, string> metadata = 8;
}
// HttpRequestBodyPassThrough preserves the corresponding input unit exactly.
message HttpRequestBodyPassThrough {}
// HttpRequestBodyTransform replaces the complete corresponding input unit.
message HttpRequestBodyTransform {
// Exactly one replacement payload is required. Present empty data deletes the
// input unit. The replacement is limited to max_payload_bytes.
oneof replacement {
// Normalized replacement request bytes.
bytes data = 1;
}
}
// HttpRequestBodyEnd reports that OpenShell sent every body unit to this stage.
// It requires no result because every preceding unit is already finalized.
message HttpRequestBodyEnd {
// Zero when STREAM_BYTES produced no units; otherwise the final input
// sequence. WHOLE_BODY_BYTES always ends at sequence 1, including for an
// empty representation.
uint64 final_sequence = 1;
}
// HttpResponseEvent is one ordered event in a stage-local response stream. A
// stream starts with exactly one preflight. An inspecting body stage then
// receives zero or more body units, one body_end, and one trailers event. Skip
@@ -210,22 +408,22 @@ message HttpResponsePreflightDecision {
// Free-form service diagnostic. OpenShell never exposes this to the sandbox
// or security logs. Limited to 4 KiB before discarding.
string reason = 3;
// Optional stable machine-readable code for audit and diagnostic output.
// Codes must start with a lowercase ASCII letter and contain only lowercase
// ASCII letters, digits, and underscores, with a maximum length of 64 bytes.
// OpenShell never returns this code to the sandbox.
string reason_code = 4;
// Audit-safe findings produced during preflight. At most 32 findings of at
// most 4 KiB encoded each are accepted.
repeated Finding findings = 4;
repeated Finding findings = 5;
// Non-secret service-defined metadata included in diagnostics. At most 64
// entries and 32 KiB of combined key/value data are accepted.
map<string, string> metadata = 5;
map<string, string> metadata = 6;
}
// HttpResponsePreflightSkip ends this stage successfully for the current
// response. OpenShell sends no body, body-end, or trailer events to the stage.
message HttpResponsePreflightSkip {
// Optional stable machine-readable code for audit output. Codes must start
// with a lowercase ASCII letter and contain only lowercase ASCII letters,
// digits, and underscores, with a maximum length of 64 bytes.
string reason_code = 1;
}
message HttpResponsePreflightSkip {}
// HttpResponsePreflightInspect selects this stage's response inspection mode
// and proposes mutations to the current response head.
@@ -298,12 +496,16 @@ message HttpResponseBodyResult {
// Free-form service diagnostic. OpenShell never exposes this to the sandbox
// or security logs. Limited to 4 KiB before discarding.
string reason = 4;
// Optional stable machine-readable code for audit and diagnostic output.
// Codes follow HttpResponsePreflightDecision.reason_code and are never
// returned to the sandbox.
string reason_code = 5;
// Audit-safe findings produced for this body unit. At most 32 findings of at
// most 4 KiB encoded each are accepted.
repeated Finding findings = 5;
repeated Finding findings = 6;
// Non-secret service-defined metadata included in diagnostics. At most 64
// entries and 32 KiB of combined key/value data are accepted.
map<string, string> metadata = 6;
map<string, string> metadata = 7;
}
// HttpResponseBodyPassThrough preserves the corresponding input unit exactly.
@@ -346,12 +548,16 @@ message HttpResponseTrailersResult {
// Free-form service diagnostic. OpenShell never exposes this to the sandbox
// or security logs. Limited to 4 KiB before discarding.
string reason = 2;
// Optional stable machine-readable code for audit and diagnostic output.
// Codes follow HttpResponsePreflightDecision.reason_code and are never
// returned to the sandbox.
string reason_code = 3;
// Audit-safe findings produced for the final trailers. At most 32 findings of
// at most 4 KiB encoded each are accepted.
repeated Finding findings = 3;
repeated Finding findings = 4;
// Non-secret service-defined metadata included in diagnostics. At most 64
// entries and 32 KiB of combined key/value data are accepted.
map<string, string> metadata = 4;
map<string, string> metadata = 5;
}
// Why OpenShell is ending an opened middleware stage stream. This enum is a
+3
View File
@@ -16,6 +16,7 @@ run = [
"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-content-guard/Cargo.toml --all-targets",
"cargo check --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml --all-targets",
]
run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native"
hide = true
@@ -27,6 +28,7 @@ run = [
"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-content-guard/Cargo.toml --all",
"cargo fmt --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml --all",
]
hide = true
@@ -37,6 +39,7 @@ run = [
"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-content-guard/Cargo.toml --all -- --check",
"cargo fmt --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml --all -- --check",
]
hide = true