Repository navigation
feat(supervisor): add HTTP response pre-return middleware evaluation #2691
Description
Activity
- addedtopic:l7Application-layer policy and inspection workApplication-layer policy and inspection workarea:supervisorProxy and routing-path workProxy and routing-path work
on Aug 10, 2026 - added a parent issue
on Aug 10, 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.
- addedstate:staleInactive item at risk of automatic closure.Inactive item at risk of automatic closure.
on Aug 25, 2026 🏗️ build-plan
Implementation plan
Issue type:
feat
Complexity: 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.
service HttpResponsePreReturn { rpc Evaluate(stream HttpResponseEvent) returns (stream HttpResponseEventResult); }
-
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
message HttpResponseEvent { oneof event { HttpResponsePreflight preflight = 1; HttpResponseBodyUnit body = 2; MiddlewareSessionEnd session_end = 3; HttpResponseTrailers trailers = 4; } } message HttpResponseEventResult { oneof result { HttpResponsePreflightResult preflight_result = 1; HttpResponseBodyResult body_result = 2; HttpResponseTrailersResult trailers_result = 3; } }
The outer
oneofleaves room for later output and finalization messages. V1 does not assign field numbers or wire semantics to those deferred messages.Preflight
message HttpResponsePreflight { RequestContext context = 1; HttpRequestTarget target = 2; uint32 status_code = 3; repeated HttpHeader headers = 4; string middleware_name = 5; google.protobuf.Struct config = 6; uint64 max_payload_bytes = 7; repeated HttpResponseBodyMode permitted_body_modes = 8; } message HttpResponsePreflightResult { oneof action { HttpResponsePreflightSkip skip = 1; HttpResponsePreflightInspect inspect = 2; HttpResponseBlockDelivery block_delivery = 7; } string reason = 3; string reason_code = 4; repeated Finding findings = 5; map<string, string> metadata = 6; } message HttpResponsePreflightSkip {} message HttpResponsePreflightInspect { HttpResponseBodyMode body_mode = 1; repeated HeaderMutation header_mutations = 2; } message HttpResponseBlockDelivery {}
- Send preflight once with the stable exchange identity, bounded request context, admitted target, final upstream status, safe response headers, middleware name, validated configuration, effective payload limit, and stage-local body-mode list.
- The middleware returns exactly one
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.- Diagnostic fields sit on the result envelope and apply to every action. Invalid diagnostics invalidate the complete result and follow
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.- Findings and metadata keep the existing audit-safe limits.
Stage-local body modes
enum HttpResponseBodyMode { HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; }
- Determine response-shape eligibility from the original final response head before applying middleware mutations.
- Apply each stage's effective
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.- For an otherwise eligible known-length response, omit
WHOLE_BODY_BYTESwhen its body is larger than the current stage's limit. - An eligible unknown-length response may select
WHOLE_BODY_BYTESand later fail withwhole_body_over_capacitythrough that stage'son_error. - Omit
STREAM_BYTESwhenmax_payload_bytesis zero. - Selecting an unlisted mode fails according to
on_error. OpenShell does not silently downgrade it.
Shared HTTP header authority
- HTTP request and response middleware may write or remove syntactically valid end-to-end fields exposed in the sanitized head. Neither direction requires an
x-openshell-middleware-prefix. - Reject writes and removals of credential, routing, framing, coding, range, hop-by-hop, and connection-nominated fields as required by the direction-specific profile.
- For response headers, reject writes but allow removal of
Accept-Ranges,ETag,Content-MD5,Digest,Content-Digest,Repr-Digest,Signature, andSignature-Input. - Use one shared validator and atomic applicator. It enforces token syntax, safe values,
on_existing, mutation counts, encoded-byte limits, connection-nominated fields, and the selected authority profile. - A later stage sees the accepted head from earlier stages. Relay code may replay only mutations accepted by the shared validator.
Body input and completion
message HttpResponseBodyUnit { uint64 sequence = 1; oneof payload { bytes data = 2; } bool end_of_stream = 3; }
- Sequence numbers are contiguous and stage-local, starting at 1.
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.- OpenShell does not read ahead, so it may send an empty final unit after the last nonempty unit.
- A stage ended by
skip_remaining, block, or failure receives no later final unit. - Decode HTTP transfer coding before middleware. Unit boundaries carry no HTTP or application meaning.
- A streaming input unit is at most
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
message HttpResponseBodyResult { uint64 sequence = 1; oneof action { HttpResponseBodyPassThrough pass_through = 2; HttpResponseBodyTransform transform = 3; HttpResponseBlockDelivery block_delivery = 8; HttpResponseBodySkipRemaining skip_remaining = 9; } string reason = 4; string reason_code = 5; repeated Finding findings = 6; map<string, string> metadata = 7; } message HttpResponseBodySkipRemaining { oneof current { HttpResponseBodyPassThrough pass_through = 1; HttpResponseBodyTransform transform = 2; } } message HttpResponseBodyTransform { oneof replacement { bytes data = 1; } }
- Require exactly one result for every body input, including the final unit.
- Results must match the next outstanding sequence. Reject zero, gaps, duplicates, and regressions.
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.- Every result fully accounts for the current input unit. V1 does not permit middleware to retain input for a later unit.
- OpenShell retains the current input until it validates the result. Fail-open can therefore bypass a failed stage using the last input OpenShell still owns.
- Diagnostic fields remain on the body-result envelope and follow the same validation rules as preflight diagnostics.
Trailers
message HttpResponseTrailers { repeated HttpHeader headers = 1; } message HttpResponseTrailersResult { repeated HeaderMutation trailer_mutations = 1; string reason = 2; string reason_code = 3; repeated Finding findings = 4; map<string, string> metadata = 5; }
- After the final body result, send exactly one trailer event to every
WHOLE_BODY_BYTESorSTREAM_BYTESstage that completes normally. Send it even when the current trailer set is empty. - Do not send trailers to
skip,HEADERS_ONLY, semantically bodyless responses, or stages ended by block, failure, orskip_remaining. - Require one explicit trailer result. An empty mutation list preserves the current set.
- Preserve wire order and repeated names as separate entries.
- Validate mutations atomically in chain order. Writes may target only a case-insensitive name present in the trailer event. V1 cannot create a missing trailer name. Removing an absent name is a no-op.
- Protect credential, routing, framing, coding, range, hop-by-hop, and connection-nominated fields.
- Apply envelope diagnostics for empty and nonempty mutation lists. Invalid diagnostics or mutations invalidate the complete result and follow
on_error.
Block delivery
- Evaluate results in policy order. Once OpenShell accepts a valid block, stop later middleware evaluation and end every still-writable opened stage with
MIDDLEWARE_DENIAL. - A failure handled earlier may already have stopped evaluation. A later block does not override it.
- Before response commitment, including preflight and
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. - For
HEAD, send the canonical headers and content length without the JSON body. - Close the downstream connection after the pre-commit denial response.
- After response commitment, including
STREAM_BYTES, abort downstream delivery. Do not add an error body, terminating chunk, or error trailer. Do not reuse the upstream connection. - The upstream request has already run. Blocking its response does not reject or roll it back.
Session termination
enum MiddlewareSessionEndReason { MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2; MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3; MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR = 6; MIDDLEWARE_SESSION_END_REASON_CANCELLATION = 7; MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE = 8; MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL = 9; MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED = 10; MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT = 11; } message MiddlewareSessionEnd { MiddlewareSessionEndReason reason = 1; MiddlewareSessionProtocolError protocol_error = 2; }
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
- Snapshot the selected chain and policy generation for the response.
- Run preflight in ascending policy order. Each stage sees the head after accepted mutations from earlier stages.
- Apply each complete preflight result atomically before opening the next stage.
- Remove
skipandHEADERS_ONLYstages from body processing after preflight. - Give every body-processing stage an independent contiguous sequence space.
- A stage's output becomes the next stage's input with fresh sequence numbers at that boundary.
- Process each bounded streaming unit through every active stage before releasing the final representation to the sandbox.
- A
WHOLE_BODY_BYTESstage creates a bounded barrier. Mixed chains may buffer before that stage and rechunk its output for a later streaming stage. - Preserve global policy order and validate results before exposing mutations or output to the next stage.
- Retain the current input representation until its result is validated so fail-open can bypass a failed stage.
skip_remainingends only the current stage. Later stages still evaluate the current unit and remaining suffix.- If a stage fails open, keep its accepted preflight mutations and validated body results. Bypass it using the current input OpenShell retained, then continue with the remaining suffix. Emit partial-coverage telemetry.
- An accepted block stops later evaluation and ends still-writable opened stages with
MIDDLEWARE_DENIAL.
HTTP relay behavior
- Relay interim 1xx responses unchanged. Start response preflight after parsing the final non-1xx upstream head and before the first sandbox write.
- Separate response parsing from downstream commitment.
- Commit the final head immediately when the active chain has no whole-body stage. If a whole-body barrier exists, delay commitment until the last barrier completes.
- Keep transport framing inside OpenShell. Decode transfer coding, repair
Content-Length, choose downstream framing, serialize trailers separately, and reject protected mutations. - Send final
HEAD, 204, and 304 responses through preflight. Accept onlyskiporHEADERS_ONLY, then finish without body or trailers. - Exclude 101 upgrades, successful CONNECT tunnels, and post-upgrade traffic from generic response-body processing.
HEADERS_ONLYmay inspect any content encoding. Body modes require absent or identityContent-Encoding.- SSE uses
STREAM_BYTESin V1. MIME type does not make an unknown-length response safe to buffer. - Original 206,
Content-Range,multipart/byteranges, andCache-Control: no-transformresponses permit onlyskiporHEADERS_ONLY. - Before a transformation-capable stream commits, remove stale range capability, validators, digests, and signatures.
- A whole-body-only chain may retain those fields only when every stage passes through.
- Drop stale upstream integrity trailers after transformation before applying trailer mutations.
Failure and lifecycle
- RPC errors, timeouts, malformed results, diagnostic violations, limit failures, and missing results follow
on_error. block_deliveryis a successful policy decision and never enterson_errorafter validation.- A known body larger than a stage limit omits
WHOLE_BODY_BYTESfor that stage. An unknown-length body that crosses the selected limit fails withwhole_body_over_capacity. - Before commitment, fail-closed middleware failure returns the canonical
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. - For
HEAD, send the same 502 headers and canonical content length without a body. - After commitment, fail-closed aborts delivery without injecting error bytes or trailers.
- Fail-open preserves accepted preflight mutations and validated output, then bypasses the failed stage using the current input OpenShell retained and continues with the suffix. Fail-open works only for bytes OpenShell still owns.
- Use the stage's effective binding timeout for every required result. Limit one unit's complete trip through the chain to the existing 30-second cap.
- Do not apply a total lifetime deadline to
STREAM_BYTES. - The RPC timeout and per-unit chain cap do not cover accumulation of a complete upstream body. Before the response dispatcher lands, define a dedicated platform-owned total deadline with a configuration source, default, start and end points, non-resetting progress behavior,
on_errorhandling, and tests. - Disconnect, upstream failure, policy reload, cancellation, denial, and middleware failure stop relevant work and end active stages with the matching reason when their streams remain writable.
Deferred extensions
- Middleware-author SDK or callback adapter.
- Explicit supervisor-owned hold or ownership operations for safe cross-unit streaming, plus asynchronous output, finalization, rolling windows, and middleware-owned spooling.
- Semantic SSE parsing and transformation.
- Creation of trailer names absent from the upstream trailer event.
- Response status mutation.
- Body-derived initial response headers.
- Content decompression and recompression.
- HTTP/2 and HTTP/3 relay support.
- Hook-specific replacements for the existing request and WebSocket APIs. Coordinate request work with feat(supervisor): add streaming HTTP request middleware evaluation #2431.
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
- Add the operation, phase-specific service, exact manifest validation, response limits, and shared HTTP header validator.
- Add ordered response preflight with stage-local modes, header mutation, diagnostics, skip, and block delivery.
- Implement
WHOLE_BODY_BYTESwith one final sequenced input and one validated result. - Implement unit-local lockstep
STREAM_BYTES,skip_remaining, per-stage sequences, and chain backpressure. - Add the fixed trailer exchange after final body results.
- Split response parsing from commitment and connect transfer decoding, evaluation, blocking, and framing repair.
- Add mixed-mode chains, failures, cancellation, policy reload, timeouts, admission, content-encoding behavior, coverage, and OCSF events.
- Add a response-capable built-in or example and update RFC 0009, architecture docs, the middleware guide, policy docs, and limit documentation.
Test plan
- Contract tests cover service discovery, exact binding selection, message names, event ordering, shared header validation, permitted non-prefixed writes, all result actions, stage-local modes, sequence ordering, required results, final empty units, unit-local streaming, skip remaining, trailer exchange, diagnostics, and terminal reasons.
- Chain tests cover ordered preflight, head mutations visible to later stages, mixed stage limits, whole-body barriers, streaming, fresh per-stage sequences, later-stage continuation after skip, partial fail-open bypass from OpenShell-owned input, block short-circuiting, capacity, cancellation, and policy reload.
- Relay tests cover content-length, chunked, close-delimited, interim 1xx, bodyless, empty bodies, identity-coded, unsupported content-coded, partial, original
no-transform, stale integrity metadata, trailers, upgrade, tunnel responses, pre-commit and post-commit block delivery, and directional disconnects. - End-to-end tests cover replacement, deletion, external middleware failure, no partial release in whole-body mode, overflow, stream-unit bounds, result and chain deadlines, streaming commitment, canonical non-HEAD and HEAD 403 and 502 responses, post-commit abort, SSE byte flushing, and OCSF redaction.
- Compatibility tests verify existing request and WebSocket implementations still compile and run.
- Future whole-body deadline tests must cover the chosen default, total rather than idle behavior, trickle input, cancellation, fail-open recovery, and fail-closed response.
- Later ownership-transfer and semantic SSE work carries its own protocol-extension tests.
Documentation impact
- Update RFC 0009 and its protocol-extensions appendix with the response hook, modes, results, trailers, blocking, commitment rules, and shared header authority.
- Update the supervisor middleware guide with response binding, preflight, body actions, limits, trailers, and failure semantics.
- Update sandbox architecture and limit docs with response commitment and admission accounting.
- Update policy docs to state that attaching middleware activates every advertised operation and phase.
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.-
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.LoadingsequenceDiagram 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.- addedstate:acceptedA maintainer decided OpenShell should pursue this issueA maintainer decided OpenShell should pursue this issueand removedstate:staleInactive item at risk of automatic closure.Inactive item at risk of automatic closure.
on Sep 1, 2026
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
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 noHTTP_RESPONSE/PRE_RETURNhook. 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_RETURNbinding 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, orblock_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 andend_of_streammarks 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_deliveryis a successful policy decision, not a middleware failure. Before response commitment, OpenShell returns its canonical403 middleware_deniedresponse. 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_BYTESis unit-local in V1: every result accounts for its complete input unit, and middleware cannot retain input across units.skip_remainingfinalizes the current unit and ends inspection for that stage while later stages continue. Middleware failures still followon_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-plancomment defines the complete contract and implementation details.Acceptance criteria
HTTP_RESPONSE/PRE_RETURNand execute it throughHttpResponsePreReturn.Evaluateusing the finalized V1 message names.skip,inspect, andblock_delivery. Result-envelope diagnostics apply to every action and invalid diagnostics followon_error.permitted_body_modesfrom the original final response head and each stage's effective payload limit. Different stages may receive different lists.x-openshell-middleware-namespace restriction. Both paths use one shared validator and atomic applicator with direction-specific protected fields.end_of_stream = true, including a present empty sequence-1 unit for an empty body-capable response.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.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.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_over_capacity. A dedicated whole-body accumulation deadline and its configuration remain required before the response dispatcher lands.no-transform, stale integrity metadata, trailers, block delivery, unit-local streaming, skip remaining, timeouts, directional disconnects, and policy reload.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
EvaluateHttpRequestRequests and responses differ in status, headers, trailers, framing, mutation authority, and commitment. They need separate contracts.