Summary
The output guardrail (hook_point: "output") only fires on the non-streaming path. When a caller requests stream: true, the gateway forwards SSE chunks from upstream verbatim and never invokes state.guardrails.check_output(...) on the assembled assistant response. As a result, a forbidden literal that the model emits inside a streamed completion reaches the caller verbatim — bypassing the output guardrail entirely.
Where the gap is
crates/aisix-proxy/src/chat.rs has exactly one check_output call site, in the non-streaming branch around line 793 (post-#196). The streaming branch (build_sse_stream in the same file) wraps the upstream chunk stream and forwards rendered chunks but never accumulates the assistant text and never calls check_output. The same gap exists for /v1/responses streaming.
Severity
MEDIUM-HIGH for any deployment that has output guardrails configured AND serves streamed completions:
- A customer enabling output guardrails reasonably expects them to apply uniformly across response shapes. There's no UI signal in the admin API saying "this guardrail is off when stream=true".
- The gap is invisible: the guardrail is configured, fires correctly on non-streaming responses, and silently does nothing on streaming. Detection requires writing the kind of e2e regression that doesn't exist today.
- For
kind: "keyword" with literal/regex match, streaming makes the bypass trivial: any caller can just add stream: true to their request to skip the policy.
What "correct" looks like
Two reasonable shapes:
A. Buffer-then-check (simplest): accumulate the assistant content across chunks, run check_output once at end-of-stream, and if it blocks: emit an SSE event: error chunk and close (per docs §5 for abnormal termination). This loses TTFB advantages of streaming but matches the semantics of the non-streaming path.
B. Streaming guardrail (more invasive): invoke an output guardrail per-chunk on the partial accumulator. Block on first match, emit error chunk, close. Lower latency to first token, but content already streamed before the match still reaches the caller — for "keep forbidden content from caller" this can leak the prefix that triggered the match.
Most customer expectations align with A: "if I configured an output guardrail, I want forbidden content blocked, I'll trade TTFB for safety." Worth a config flag if shape B is also desired.
Repro shape (for the held-back e2e)
// Mock upstream emits SSE chunks; one of them carries FORBIDDEN_WORD
upstream = await startOpenAiUpstream({
streamEvents: [
`{"id":"x","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}`,
`{"id":"x","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"delta":{"content":"sure here it is: ${FORBIDDEN_WORD}"},"finish_reason":null}]}`,
`{"id":"x","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"stop"}]}`,
"[DONE]",
],
});
// Configure output keyword guardrail on FORBIDDEN_WORD.
// Caller streams.
// Expected: stream throws (or emits SSE error event) BEFORE FORBIDDEN_WORD reaches the iterator.
// Today: the FORBIDDEN_WORD is delivered to the caller verbatim. Bug.
Surfaced by
Independent audit on PR #203 (#153 redaction fix). Audit MEDIUM-1: "streaming output path has no guardrail and no redaction test". This issue is out of scope for the redaction fix (which is about the error envelope shape) but is the same product surface — output guardrails — and affects the same security contract.
Companion
Summary
The output guardrail (
hook_point: "output") only fires on the non-streaming path. When a caller requestsstream: true, the gateway forwards SSE chunks from upstream verbatim and never invokesstate.guardrails.check_output(...)on the assembled assistant response. As a result, a forbidden literal that the model emits inside a streamed completion reaches the caller verbatim — bypassing the output guardrail entirely.Where the gap is
crates/aisix-proxy/src/chat.rshas exactly onecheck_outputcall site, in the non-streaming branch around line 793 (post-#196). The streaming branch (build_sse_streamin the same file) wraps the upstream chunk stream and forwards rendered chunks but never accumulates the assistant text and never callscheck_output. The same gap exists for/v1/responsesstreaming.Severity
MEDIUM-HIGHfor any deployment that has output guardrails configured AND serves streamed completions:kind: "keyword"with literal/regex match, streaming makes the bypass trivial: any caller can just addstream: trueto their request to skip the policy.What "correct" looks like
Two reasonable shapes:
A. Buffer-then-check (simplest): accumulate the assistant content across chunks, run
check_outputonce at end-of-stream, and if it blocks: emit an SSEevent: errorchunk and close (per docs §5 for abnormal termination). This loses TTFB advantages of streaming but matches the semantics of the non-streaming path.B. Streaming guardrail (more invasive): invoke an output guardrail per-chunk on the partial accumulator. Block on first match, emit error chunk, close. Lower latency to first token, but content already streamed before the match still reaches the caller — for "keep forbidden content from caller" this can leak the prefix that triggered the match.
Most customer expectations align with A: "if I configured an output guardrail, I want forbidden content blocked, I'll trade TTFB for safety." Worth a config flag if shape B is also desired.
Repro shape (for the held-back e2e)
Surfaced by
Independent audit on PR #203 (#153 redaction fix). Audit MEDIUM-1: "streaming output path has no guardrail and no redaction test". This issue is out of scope for the redaction fix (which is about the error envelope shape) but is the same product surface — output guardrails — and affects the same security contract.
Companion