Repository navigation
feat(supervisor): add HTTP response pre-return middleware evaluation #2691
Description
User story
As an operator, I want attached supervisor middleware to inspect, transform, or block HTTP responses before the sandbox receives them.
Problem statement
Supervisor middleware supports HTTP_REQUEST/PRE_CREDENTIALS, but it has no HTTP_RESPONSE/PRE_RETURN hook. OpenShell therefore forwards upstream responses without running attached response policy. Asynchronous inspection cannot enforce this hook because it cannot recall bytes already delivered to the sandbox.
Parent: #1733.
Impact / why this matters
Operators cannot apply generic response controls such as PII detection, redaction, secret scanning, or compliance filtering in the configured middleware chain. The current workaround requires another proxy and loses OpenShell's policy order, failure behavior, and audit context.
Proposed design
Add an HTTP_RESPONSE/PRE_RETURN binding and a hook-specific bidirectional inspection API. The API models HTTP response semantics directly rather than reusing the request contract.
Each selected stage receives a preflight with the final response status, safe headers, request context, stage-local body-mode choices, and supervisor limits. It may skip, inspect, or block_delivery. An inspecting stage selects headers-only processing, one bounded whole-body value, or normalized response-body units. Every body unit has a stage-local sequence and end_of_stream marks the final result-bearing unit.
Body-inspecting stages that complete normally receive one normalized trailer event, including when the trailer set is empty, and return one trailer result. V1 can modify or remove existing trailer names but cannot create a name absent from the event.
block_delivery is a successful policy decision, not a middleware failure. Before response commitment, OpenShell returns its canonical 403 middleware_denied response. After commitment, OpenShell aborts downstream delivery. The upstream request has already run, so blocking its response does not reject or roll back that request.
STREAM_BYTES is unit-local in V1: every result accounts for its complete input unit, and middleware cannot retain input across units. skip_remaining finalizes the current unit and ends inspection for that stage while later stages continue. Middleware failures still follow on_error. Fail-open continues from the last input OpenShell still owns; fail-closed returns a clean error before commitment and aborts after commitment.
Policy attachment remains unchanged. Attaching middleware activates every operation and phase advertised by its manifest.
This issue includes the raw hook protocol and one response-capable example. A middleware-author SDK or callback adapter is follow-up work.
The build-plan comment defines the complete contract and implementation details.
Acceptance criteria
- An attached service can advertise
HTTP_RESPONSE/PRE_RETURNand execute it throughHttpResponsePreReturn.Evaluateusing the finalized V1 message names. - Preflight supports
skip,inspect, andblock_delivery. Result-envelope diagnostics apply to every action and invalid diagnostics followon_error. - OpenShell derives
permitted_body_modesfrom the original final response head and each stage's effective payload limit. Different stages may receive different lists. - HTTP request and response middleware can write permitted end-to-end headers without an
x-openshell-middleware-namespace restriction. Both paths use one shared validator and atomic applicator with direction-specific protected fields. - V1 supports headers-only inspection, bounded complete-body inspection, and normalized lockstep byte streaming.
- Every normally completed body inspection receives one final result-bearing unit with
end_of_stream = true, including a present empty sequence-1 unit for an empty body-capable response. - Every body input receives exactly one ordered
pass_through,transform,block_delivery, orskip_remainingresult with the matching stage-local sequence. -
STREAM_BYTESis unit-local. Every result fully accounts for the current input, and V1 does not permit cross-unit retention. -
skip_remainingends body and trailer events only for that stage. The current and later units continue through later stages. - A normally completed whole-body or streaming stage receives one trailer event after its final body result, including for an empty trailer set, and returns one trailer result.
- V1 trailer results may modify or remove names present in the trailer event. They cannot introduce a missing name.
- OpenShell does not expose HTTP/1 transfer chunks or socket-read boundaries to middleware.
- OpenShell repairs downstream framing after transformation and excludes protocol upgrades from generic response-body processing.
- OpenShell relays interim 1xx responses unchanged and evaluates only the final non-1xx response head.
- Before commitment,
block_deliveryreturns the canonical403 middleware_denied;HEADreceives the same headers and content length without a body. After commitment, OpenShell aborts delivery and does not reuse upstream. - Before commitment, fail-closed middleware failure returns the canonical
502 response_delivery_failed;HEADreceives the same headers and content length without a body. After commitment, fail-closed aborts delivery. -
STREAM_BYTESinputs are at mostmin(64 KiB, max_payload_bytes). Each result uses the effective binding timeout and the existing 30-second per-unit chain cap. Streaming has no total response-lifetime deadline. - Whole-body capacity failures use
whole_body_over_capacity. A dedicated whole-body accumulation deadline and its configuration remain required before the response dispatcher lands. - Shared terminal reasons distinguish sandbox and upstream disconnects.
- Tests cover ordered and mixed-profile chains, bodyless responses, content-length, chunked and close-delimited bodies, replacement, whole-body overflow, non-identity content encoding, partial responses, original
no-transform, stale integrity metadata, trailers, block delivery, unit-local streaming, skip remaining, timeouts, directional disconnects, and policy reload. - OCSF events omit response content, protected headers, and free-form middleware reasons.
Alternatives considered
Use one universal inspection RPC
HTTP requests, HTTP responses, and WebSocket messages have different heads, boundaries, mutation rules, commitment points, and denial behavior. A universal event union would move those rules out of the interface and into prose and runtime validation. The implementation may share flow-control machinery, but the public API remains hook-specific.
Observe responses asynchronously
This preserves latency but permits partial disclosure, so it cannot enforce PRE_RETURN.
Add only a unary buffered response RPC
This cannot support SSE, long-lived responses, or later rolling-window processing without introducing another API.
Reuse EvaluateHttpRequest
Requests and responses differ in status, headers, trailers, framing, mutation authority, and commitment. They need separate contracts.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
github-actions commentedon Aug 25, 2026
This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.
pimlock commentedon Aug 31, 2026
Implementation plan
Issue type:
featComplexity: High
Confidence: Medium. V1 is lockstep and bounded, but response commitment, framing repair, trailers, blocking, and mixed middleware chains need integration coverage.
Summary
Add
HTTP_RESPONSE/PRE_RETURNthrough a hook-specific bidirectional gRPC API. Each selected middleware stage receives its own ordered response stream after OpenShell receives the final upstream response head and before it delivers that response to the sandbox.V1 supports preflight, response-header mutation, headers-only evaluation, one bounded whole-body value, lockstep byte streaming, response blocking, and response trailers. Every body input has a stage-local sequence number and receives exactly one result. The final result-bearing body unit sets
end_of_stream = true.STREAM_BYTESis unit-local in V1. Every result accounts for its complete input unit, and middleware cannot retain input across units. A stage may useskip_remainingto finalize the current unit and stop its own body inspection while later stages continue.The event and result envelopes remain additive. Later work can add ownership transfer, asynchronous output, input finalization checkpoints, semantic SSE events, and creation of new trailer names without changing RPC cardinality.
Policy attachment stays unchanged. Attaching middleware activates every operation and phase advertised by its service manifest. This issue does not add a policy-side hook selector.
This issue defines the raw hook protocol and adds one response-capable built-in or example. A middleware-author SDK or callback adapter is follow-up work.
Public contract
Operation and service
Add
HTTP_RESPONSEtoSupervisorMiddlewareOperationand accept the exactHTTP_RESPONSE/PRE_RETURNbinding.Use the V1 service and RPC names below.
Serve this phase-specific service alongside the existing
SupervisorMiddlewarediscovery and configuration service. Do not add a required method toSupervisorMiddleware.Use
RequestContext.request_idas the exchange identity across request and response evaluation. Do not keep request evaluation open while waiting for the response.Open one ordered stream per selected middleware stage and HTTP response.
Event envelopes
The outer
oneofleaves room for later output and finalization messages. V1 does not assign field numbers or wire semantics to those deferred messages.Preflight
skip,inspect, orblock_deliveryaction.skipends only that stage for this response. It succeeds without invokingon_error.inspectselects one listed body mode and may include permitted response-header mutations. OpenShell validates the complete result atomically before opening the next stage.block_deliveryis a successful policy decision enforced independently ofon_error. Invalid block results remain middleware failures.on_error.reasonis limited to 4 KiB and never reaches the sandbox or security logs.reason_codeuses theHttpRequestResult.reason_codeformat and 64-byte limit. OpenShell returns it to the sandbox only for an accepted pre-commit block.Stage-local body modes
max_payload_bytesafter response-shape filtering. Stages with different limits may receive differentpermitted_body_modeslists.HEADERS_ONLYis always present and is the only mode for bodyless, partial, encoded, or originalno-transformresponses.WHOLE_BODY_BYTESwhen its body is larger than the current stage's limit.WHOLE_BODY_BYTESand later fail withwhole_body_over_capacitythrough that stage'son_error.STREAM_BYTESwhenmax_payload_bytesis zero.on_error. OpenShell does not silently downgrade it.Shared HTTP header authority
x-openshell-middleware-prefix.Accept-Ranges,ETag,Content-MD5,Digest,Content-Digest,Repr-Digest,Signature, andSignature-Input.on_existing, mutation counts, encoded-byte limits, connection-nominated fields, and the selected authority profile.Body input and completion
WHOLE_BODY_BYTESsends exactly one final body unit for every body-capable response. A zero-length representation uses present emptydataat sequence 1 withend_of_stream = true.STREAM_BYTESsends one or more units to a stage that completes normally. Exactly one hasend_of_stream = true.skip_remaining, block, or failure receives no later final unit.min(64 KiB, max_payload_bytes). OpenShell may send shorter units to preserve flushing and backpressure.STREAM_BYTEShas no total response-lifetime deadline.Body result
pass_throughforwards the input unchanged.transformreplaces the complete input. A present empty replacement deletes it.block_deliverystops response delivery under the shared block contract.skip_remainingfinalizes the current unit with a nested pass-through or transform, then ends body and trailer events for this stage. The current and later units continue through later stages.Trailers
WHOLE_BODY_BYTESorSTREAM_BYTESstage that completes normally. Send it even when the current trailer set is empty.skip,HEADERS_ONLY, semantically bodyless responses, or stages ended by block, failure, orskip_remaining.on_error.Block delivery
MIDDLEWARE_DENIAL.WHOLE_BODY_BYTES, replace the upstream response with the canonical403 Forbiddenmiddleware-denial response. Its JSON body haserror: middleware_deniedand includes a validatedreason_codewhen supplied. Never return free-formreason.HEAD, send the canonical headers and content length without the JSON body.STREAM_BYTES, abort downstream delivery. Do not add an error body, terminating chunk, or error trailer. Do not reuse the upstream connection.Session termination
Send at most one best-effort
session_end. It receives no result.DOWNSTREAM_DISCONNECTmeans the sandbox peer disconnected.UPSTREAM_DISCONNECTmeans upstream disconnected after a valid response or upgrade.UPSTREAM_FAILUREmeans upstream rejected or failed before that point.Chain behavior
skipandHEADERS_ONLYstages from body processing after preflight.WHOLE_BODY_BYTESstage creates a bounded barrier. Mixed chains may buffer before that stage and rechunk its output for a later streaming stage.skip_remainingends only the current stage. Later stages still evaluate the current unit and remaining suffix.MIDDLEWARE_DENIAL.HTTP relay behavior
Content-Length, choose downstream framing, serialize trailers separately, and reject protected mutations.HEAD, 204, and 304 responses through preflight. Accept onlyskiporHEADERS_ONLY, then finish without body or trailers.HEADERS_ONLYmay inspect any content encoding. Body modes require absent or identityContent-Encoding.STREAM_BYTESin V1. MIME type does not make an unknown-length response safe to buffer.Content-Range,multipart/byteranges, andCache-Control: no-transformresponses permit onlyskiporHEADERS_ONLY.Failure and lifecycle
on_error.block_deliveryis a successful policy decision and never enterson_errorafter validation.WHOLE_BODY_BYTESfor that stage. An unknown-length body that crosses the selected limit fails withwhole_body_over_capacity.502 Bad Gatewayresponse witherror: response_delivery_failed, bounded L7 context, and a warning that retrying may repeat upstream side effects. It excludes middleware names, free-form reasons, reason codes, upstream status, headers, and content.HEAD, send the same 502 headers and canonical content length without a body.STREAM_BYTES.on_errorhandling, and tests.Deferred extensions
Scope
proto/supervisor_middleware.proto: operation, service, response events and results, preflight modes, body results, trailers, and shared terminal reasons.crates/openshell-core/src/middleware.rs: response limits and admission accounting.crates/openshell-supervisor-middleware/src/: exact binding validation, ordered response chains, result validation, diagnostics, and remote clients.crates/openshell-supervisor-network/src/l7/: response parsing, commitment, normalization, policy integration, framing repair, and OCSF events.crates/openshell-supervisor-middleware-builtins/and the content-guard example: one response-capable implementation.Implementation steps
WHOLE_BODY_BYTESwith one final sequenced input and one validated result.STREAM_BYTES,skip_remaining, per-stage sequences, and chain backpressure.Test plan
no-transform, stale integrity metadata, trailers, upgrade, tunnel responses, pre-commit and post-commit block delivery, and directional disconnects.Documentation impact
Revision 14: incorporated the API review of PR #3073. Renamed the preflight result, replaced
body_endwith a final result-bearing body unit, made body modes stage-local, kept streaming unit-local while retainingskip_remaining, completedblock_delivery, added the fixed V1 trailer exchange, kept diagnostics on result envelopes, clarified directional disconnect reasons, and deferred cross-unit streaming and the undefined whole-body accumulation deadline until each has an explicit ownership or configuration model.pimlock commentedon Aug 31, 2026
HTTP response middleware flow
The response hook begins after OpenShell sends the request upstream and receives the final non-1xx response head. It controls delivery to the sandbox. It cannot undo the upstream request, so callers must assume that upstream side effects may already have occurred. OpenShell calls the phase-specific
HttpResponsePreReturn.Evaluatestream for each selected response stage.sequenceDiagram participant C as Sandbox client participant S as OpenShell supervisor participant U as Upstream participant M as Current response stage C->>S: HTTP request S->>U: Forward request opt Interim 1xx except 101 U-->>S: Interim response S-->>C: Relay unchanged end U-->>S: Final non-1xx response head Note over S,U: HTTP_RESPONSE/PRE_RETURN starts here loop Each selected stage in policy order S->>M: Preflight with current head, context, stage-local modes, and limits alt SKIP M-->>S: preflight_result.skip plus optional diagnostics S->>S: Remove this stage for the response else BLOCK_DELIVERY M-->>S: preflight_result.block_delivery S->>S: Stop later evaluation and deny delivery else INSPECT M-->>S: preflight_result.inspect with body mode and head mutations S->>S: Validate result and apply mutations atomically end end alt HEAD, 204, or 304 Note over S,M: Only SKIP or HEADERS_ONLY is valid S-->>C: Commit processed head and finish else WHOLE_BODY_BYTES barrier exists U-->>S: Complete normalized identity-coded body Note over S: Enforce each whole-body stage limit before commitment loop Each active body stage in policy order S->>M: Final body unit with stage-local sequence and end_of_stream M-->>S: Matching body result end S-->>C: Commit processed head and body else STREAM_BYTES S-->>C: Commit processed head with streaming framing loop Each bounded normalized byte unit U-->>S: Response body bytes loop Each active body stage in policy order S->>M: Body unit with stage-local sequence M-->>S: Matching body result end S-->>C: Send validated output end S->>M: Final result-bearing body unit with end_of_stream M-->>S: Matching final body result end opt Normally completed WHOLE_BODY_BYTES or STREAM_BYTES stage S->>M: Normalized trailers, including an empty set M-->>S: One trailer result S-->>C: Validated final trailers end S-->>M: Best-effort session_endEach stage has its own contiguous sequence space. OpenShell gives a later stage fresh sequence numbers after an earlier stage changes output length or cardinality. V1 waits for exactly one result per input unit. Silence is a timeout, not a buffering request.
WHOLE_BODY_BYTESsends one final unit for every body-capable response. An empty representation uses present emptydataat sequence 1.STREAM_BYTESalso ends with one result-bearing unit whoseend_of_streamflag is true. OpenShell may send an empty final unit when it learns about EOF after forwarding the last nonempty unit.OpenShell derives response-shape eligibility from the original final head, then applies each stage's effective
max_payload_bytes. Stages with different limits may receive differentpermitted_body_modeslists. An eligible unknown-length response may selectWHOLE_BODY_BYTESand later fail withwhole_body_over_capacity.Streaming input units are at most
min(64 KiB, max_payload_bytes). Each result accounts for its complete input unit; V1 does not permit cross-unit retention.skip_remainingends body and trailer events for that stage only; later stages continue.Every body-inspecting stage that completes normally receives one trailer event after its final body result, even when no trailers exist. A trailer result may modify or remove names in that event. V1 cannot create a missing trailer name.
OpenShell decodes HTTP transfer coding before middleware and repairs downstream framing after transformation. V1 body inspection accepts only absent or identity
Content-Encoding.HEADERS_ONLYmay inspect any content encoding.Request and response header mutations use one shared validator and atomic applicator. It permits syntactically valid end-to-end writes without an
x-openshell-middleware-prefix and applies direction-specific protected-header rules.The original final head controls response-shape eligibility. A 206 response,
Content-Range,multipart/byteranges, or originalCache-Control: no-transformpermits onlySKIPorHEADERS_ONLY. Middleware cannot remove those constraints and then select a body mode.block_deliveryis a successful policy decision enforced independently ofon_error. Before commitment, OpenShell returns its canonical403 middleware_deniedresponse and closes downstream. After commitment, it aborts delivery without adding an error body, terminating chunk, or error trailer. The upstream request has already run.Middleware failures follow
on_error. Before commitment, fail-closed returns the canonical502 response_delivery_failed. After commitment, it aborts the response. Fail-open preserves accepted preflight mutations and validated output, then bypasses the failed stage using the current input OpenShell retained and continues with the remaining suffix. The governing rule is that fail-open works only for bytes OpenShell still owns.The per-result middleware timeout and 30-second per-unit chain cap do not define a whole-body accumulation deadline. That deadline needs a dedicated configuration source and default before the response dispatcher lands.
STREAM_BYTEShas no total response-lifetime deadline.The shared terminal enum distinguishes
DOWNSTREAM_DISCONNECT, where the sandbox peer disconnected, fromUPSTREAM_DISCONNECT, where upstream disconnected after a valid response or upgrade.