Skip to content

fix(proxy): run output guardrails on streaming responses - #222

Merged
moonming merged 2 commits into
mainfrom
fix/204-streaming-output-guardrail
May 10, 2026
Merged

fix(proxy): run output guardrails on streaming responses#222
moonming merged 2 commits into
mainfrom
fix/204-streaming-output-guardrail

Conversation

@moonming

@moonming moonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes #204.

Problem

Pre-fix the streaming path skipped output guardrails entirely. A kind: "keyword" deny-list configured on hook_point: "output" fired correctly on non-streaming responses but was trivially bypassable by setting stream: true — the gateway forwarded SSE chunks verbatim and never accumulated the assistant text for guardrail evaluation. A forbidden literal in a streamed completion reached the caller untouched.

A security control that only fires when someone forgets to enable a flag isn't a security control. The de-facto reference implementation in this category treats streaming output guardrails as a baseline requirement.

Fix — buffer-then-check at end-of-stream

build_sse_stream now takes an optional StreamGuardrailContext and accumulates chunk.delta.content into a String buffer. After the upstream stream completes cleanly (no upstream error), the gateway calls chain.check_output(...) on a synthesized ChatResponse. On Block:

  • Emit SSE event: error frame with OpenAI envelope (error.type: "content_filter", redacted "response blocked by content policy" message — mirrors bug: output guardrail error message echoes the matched forbidden literal back to caller #153's non-streaming wire contract)
  • Suppress terminal [DONE] so SDK consumers detect the truncation (per docs §5)
  • tracing::warn! with the rich verdict reason (operator-side carries matched detail; wire-side does not)
  • Mark comp.guardrail_blocked = true so post-stream telemetry records the block
if let aisix_guardrails::GuardrailVerdict::Block { reason } =
    ctx.chain.check_output(&synthesized).await
{
    tracing::warn!(guardrail_hook = "output", model = %ctx.model_name,
                   reason = %reason, "guardrail blocked streaming response");
    errored = true;
    guard.comp().guardrail_blocked = true;
    yield Ok::<_, Infallible>(
        Event::default().event("error").data(error_frame_payload(
            "content_filter",
            "response blocked by content policy",
        )),
    );
}

Why buffer-then-check vs per-chunk

Per-chunk evaluation is wrong for blocking guardrails: by the time chunk N matches the forbidden literal, chunks 1..N-1 have already been emitted — the secret leaks regardless. Buffer-then-check trades latency-to-first-completion for the security guarantee.

Streaming masking-style guardrails (where partial leakage is repaired by rewriting tokens, e.g. PII redaction) could use a different cadence; that's a v2 concern, not in scope here. The kind: "keyword" deny-list use case in #204 is the blocking variant.

Trade-off — partial leakage of the chunks BEFORE the block fires

The pre-emitted data: ... chunks (chunks 1..N) DO carry the partial assistant content to the caller before the buffer-then-check completes. The security guarantee is "the stream did NOT close cleanly, AND an SSE event: error frame surfaces the block" — not "byte-perfect prevention of all leakage". Preventing every single byte from reaching the wire would require holding ALL chunks server-side until the check fires, which negates streaming's latency-to-first-token benefit.

This trade-off matches the de-facto reference implementation's approach for buffer-then-check guardrails.

Tests

Rust unit (crates/aisix-proxy/src/lib.rs)streaming_output_guardrail_blocks_with_sse_error_event_and_no_done:

  • Drains raw response bytes through the full proxy stack
  • Asserts !wire.contains("data: [DONE]") — terminal sentinel suppressed
  • Asserts wire.contains("event: error") — error frame emitted
  • Asserts error-frame data parses as JSON with error.type === "content_filter"
  • Asserts error message equals the redacted static string
  • Asserts error message does NOT contain the matched literal

E2E (tests/e2e/src/cases/guardrail-output-e2e.test.ts) — added streaming case to the existing describe. Separate streaming upstream + Model + caller key sharing the env-wide guardrail policy. Raw fetch with stream: true. Asserts the wire-level shape end-to-end.

Verification

  • cargo test -p aisix-proxy --lib: 158/158 passing
  • cargo test -p aisix-guardrails --lib: 42/42 passing
  • cargo clippy --workspace --lib --tests -- -D warnings: clean
  • cargo fmt --check: clean
  • pnpm tsc --noEmit (e2e): clean

Audit follow-ups (commit b799761)

Independent audit on initial push surfaced 1 HIGH + 3 MEDIUM + 2 LOW. Resolved as follows:

  • HIGH-1 → fixed: output Bypass verdict at end-of-stream was silently dropped from telemetry. Added bypass_reason: String field to StreamCompletion; on Bypass arm, capture with first-bypass-wins semantics (matches non-streaming convention). The on_complete closure merges with the input-side bypass_reason_for_telem snapshot before emitting telemetry.

  • MEDIUM-2 → fixed: no fast-path skip when the guardrail chain is empty. Added is_empty(&self) -> bool default-method to Guardrail trait (returns false — safe default for custom impls); GuardrailChain overrides. Streaming call site now passes Some(StreamGuardrailContext) only when !state.guardrails.is_empty(); guardrail-free deployments skip both per-chunk content accumulation and the post-loop synthesized-ChatResponse build.

  • MEDIUM-3 → fixed: e2e didn't cover the streaming-Allow path. Added a companion case in guardrail-output-e2e.test.ts with a separate clean upstream + Model + caller emitting content NOT containing FORBIDDEN_WORD. Asserts terminal [DONE] IS present, no event: error, full assistant content reaches the wire. A regression that ALWAYS blocks would have passed the existing block-case test alone — this companion catches it.

  • MEDIUM-1 (client-disconnect bypasses guardrail check) → filed as bug: streaming output guardrail check is skipped on client disconnect (telemetry under-counts blocks) #223: the post-loop check sits after while let Some(item) = upstream.next().await; client disconnect drops the generator at the last yield suspension, so the post-loop code never runs and comp.guardrail_blocked stays false even when the buffered content would have blocked. Telemetry under-counts disconnect-as-block. Out of scope for this PR (already a step forward vs the pre-fix "every streaming request bypasses the guardrail"). The recommended fix shape is to spawn a detached task from the CompleteOnDrop Drop impl — tracked as a separate change because it requires async-from-Drop scaffolding that isn't in this PR.

  • LOW-1 (cosmetic — model_name field used only in tracing line) → deferred: current shape is fine.

  • LOW-2 (cosmetic — ChatMessage::assistant(content.clone()) clones) → deferred: gated behind the is_empty() fast-path now (M2 fix), so the clone only happens on guardrail-active deployments where the cost is negligible vs upstream I/O.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content guardrails now properly block harmful content in streaming responses (previously bypassed for stream mode).
    • Blocked streaming responses return a proper error message instead of incomplete content.
    • Telemetry correctly reports when streaming requests are blocked by guardrails.
  • Tests

    • Added comprehensive test coverage for streaming content guardrail behavior.

Review Change Stack

## Problem

Pre-fix the streaming path (`build_sse_stream` in
`crates/aisix-proxy/src/chat.rs`) skipped output guardrails entirely.
A `kind: "keyword"` deny-list configured on `hook_point: "output"`
fired correctly on non-streaming responses but was trivially
bypassable by setting `stream: true` — the gateway forwarded SSE
chunks verbatim and never accumulated the assistant text for
guardrail evaluation. So a forbidden literal in a streamed
completion reached the caller untouched.

A guardrail that only fires when someone forgets to enable a flag
isn't a security control. The reference implementation in this
category treats streaming output guardrails as a baseline
requirement (every streaming chat-completion request goes through
the guardrail dispatcher).

## Fix

Buffer-then-check at end-of-stream:

1. `build_sse_stream` now takes an optional `StreamGuardrailContext`
   (a typed wrapper around `Arc<dyn Guardrail>` + the model name for
   tracing). When set, the stream loop accumulates `chunk.delta.content`
   into a `String` buffer.
2. After the loop completes cleanly (no upstream error), if a
   guardrail context is present, synthesize a `ChatResponse` from the
   accumulated content + the StreamCompletion's tracked usage stats
   and call `chain.check_output(&resp).await`.
3. On `Block { reason }`:
   - Mirror #153's wire-level redaction: emit an SSE `event: error`
     frame with the OpenAI envelope shape (`error.type:
     "content_filter"`, generic `"response blocked by content policy"`
     message — no matched-literal leak).
   - Set `errored = true` so the terminal `[DONE]` is suppressed
     (per docs §5 abnormal-termination contract).
   - Emit `tracing::warn!` with the rich verdict reason (operator-
     side log carries the matched detail; wire-side does not).
   - Set `comp.guardrail_blocked = true` so the post-stream
     telemetry callback records the block on `usage_events`.

The call site in `dispatch_streaming` always passes `Some(ctx)` —
the chain itself short-circuits when no policies are configured, so
the per-chunk overhead for guardrail-free deployments is just a
single `Option::is_some()` check + an unused `String` allocation.

## Trade-off (documented)

Per-chunk evaluation is wrong for blocking guardrails: by the time
chunk N matches the forbidden literal, chunks 1..N-1 have already
been emitted — the secret leaks regardless. Buffer-then-check
trades latency-to-first-completion for the security guarantee.
Streaming masking-style guardrails (where partial leakage is
repaired by rewriting tokens) could use a different cadence; that's
a v2 concern.

## Tests

**Rust unit (`crates/aisix-proxy/src/lib.rs`)** — new
`streaming_output_guardrail_blocks_with_sse_error_event_and_no_done`
test that drains raw response bytes through the full proxy stack
and asserts:
- `!wire.contains("data: [DONE]")` — terminal sentinel suppressed
- `wire.contains("event: error")` — error frame emitted
- error-frame data parses as JSON with `error.type === "content_filter"`
- error message equals the redacted static string
- error message does NOT contain the matched literal

**E2E (`tests/e2e/src/cases/guardrail-output-e2e.test.ts`)** — added
streaming case to the existing output-guardrail describe block.
Sets up a separate streaming upstream + Model + caller key sharing
the existing env-wide guardrail policy. Sends `stream: true` request
via raw `fetch`, asserts the wire-level shape (no `[DONE]`,
`event: error` present, OpenAI envelope, redacted message, no
forbidden literal in the error envelope, upstream IS hit per
buffer-then-check semantics).

## Verification

- `cargo test -p aisix-proxy --lib`: **158/158 passing** (incl. new test)
- `cargo clippy --workspace --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean

## References

- Issue: #204 (`bug` + `vulnerability`)
- `docs/api-proxy.md` §5 (abnormal-termination contract — same SSE
  shape this fix uses for guardrail blocks)
- #153 (non-streaming guardrail redaction — same wire-level
  contract mirrored to streaming)
- #198 (`error_frame_payload` helper — reused here)
Copilot AI review requested due to automatic review settings May 10, 2026 12:45
@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: d48a11ad-0718-4cae-a0bf-1f03db369ca1

📥 Commits

Reviewing files that changed from the base of the PR and between d9e6ceb and b799761.

📒 Files selected for processing (4)
  • crates/aisix-guardrails/src/chain.rs
  • crates/aisix-guardrails/src/lib.rs
  • crates/aisix-proxy/src/chat.rs
  • tests/e2e/src/cases/guardrail-output-e2e.test.ts
✅ Files skipped from review due to trivial changes (1)
  • crates/aisix-guardrails/src/chain.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/aisix-proxy/src/chat.rs

📝 Walkthrough

Walkthrough

The pull request adds end-of-stream output guardrail evaluation for streaming chat responses. A new StreamGuardrailContext carries guardrail configuration into the SSE builder, which conditionally buffers assistant content. At completion, if a guardrail is configured, the buffered response is evaluated; blocks emit an SSE error frame with redacted content_filter payload and suppress the terminal [DONE]. Telemetry propagates the guardrail block state. Integration and E2E tests validate the behavior.

Changes

Streaming Output Guardrail Blocking

Layer / File(s) Summary
Guardrail trait & chain
crates/aisix-guardrails/src/lib.rs, crates/aisix-guardrails/src/chain.rs
Adds Guardrail::is_empty(&self) -> bool and GuardrailChain::is_empty() to signal no-op guardrail configurations.
Data Types and Contracts
crates/aisix-proxy/src/chat.rs
StreamCompletion gains guardrail_blocked: bool; new StreamGuardrailContext struct carries guardrail chain and model name for end-of-stream evaluation.
Streaming Dispatch and Telemetry
crates/aisix-proxy/src/chat.rs
Streaming dispatch constructs StreamGuardrailContext and passes it to build_sse_stream; telemetry emission uses the stream completion's guardrail_blocked flag.
SSE Stream Builder and Buffer Management
crates/aisix-proxy/src/chat.rs
build_sse_stream signature extended with optional output_guardrail parameter; buffer allocation and assistant text accumulation are conditional on guardrail configuration.
Chunk Processing
crates/aisix-proxy/src/chat.rs
While processing upstream SSE chunks, assistant delta content is appended into the buffer when buffering is enabled.
End-of-Stream Guardrail Evaluation
crates/aisix-proxy/src/chat.rs
On stream end synthesize a ChatResponse from buffered content, run check_output; on Block set guardrail_blocked, emit SSE event: error with content_filter redacted payload, and suppress [DONE].
Comments
crates/aisix-proxy/src/chat.rs
Update describing errored-skip behavior to include blocks from streaming output guardrail.
Integration Test
crates/aisix-proxy/src/lib.rs
New regression test streaming_output_guardrail_blocks_with_sse_error_event_and_no_done validates SSE error frame emission, [DONE] suppression, and redacted error envelope.
E2E Test Infrastructure
tests/e2e/src/cases/guardrail-output-e2e.test.ts
Adds streamUpstream mock SSE server, streaming provider/model definition, and an API key scoped to the streaming model; updates teardown.
E2E Streaming Tests
tests/e2e/src/cases/guardrail-output-e2e.test.ts
Adds streaming “blocked” test asserting SSE event: error without data: [DONE] and redacted content_filter payload, and an “allow” test asserting normal data: [DONE] completion with full content.

🎯 4 (Complex) | ⏱️ ~45 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes #204 by ensuring output guardrails are enforced for streaming /v1/chat/completions responses (previously bypassable with stream: true). It implements a buffer-then-check strategy: accumulate streamed assistant content, run check_output after the upstream stream completes, and on Block emit an SSE event: error with a redacted OpenAI-style error envelope and omit [DONE].

Changes:

  • Run output guardrails at end-of-stream for streaming chat completions; emit SSE event: error and suppress [DONE] on block.
  • Add guardrail_blocked propagation into streaming telemetry (UsageEvent.guardrail_blocked).
  • Add Rust unit + E2E coverage pinning the wire-level contract for streaming blocks (error event + no [DONE] + redacted message/type).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
crates/aisix-proxy/src/chat.rs Adds streaming output-guardrail buffering/checking and propagates guardrail_blocked into stream completion telemetry.
crates/aisix-proxy/src/lib.rs Adds a unit test asserting streaming guardrail blocks emit SSE error + omit [DONE] and redact matched literal in the error envelope.
tests/e2e/src/cases/guardrail-output-e2e.test.ts Extends existing guardrail E2E to cover the streaming wire shape end-to-end.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/aisix-proxy/src/chat.rs Outdated
Comment on lines 512 to 519
// Per #204: pass the gateway's guardrail chain so the
// streaming path can run output guardrails at end-of-stream
// (buffer-then-check). Mirrors the non-streaming
// `state.guardrails.check_output(...)` call site.
let stream_guardrail = Some(StreamGuardrailContext {
chain: Arc::clone(&state.guardrails),
model_name: req.model.clone(),
});
Comment on lines +1210 to +1219
// Per #204: accumulate the assistant's content across chunks
// so the output guardrail can evaluate the full response at
// end-of-stream. Allocates only when an output guardrail is
// configured AND the upstream actually emits content; for
// requests without an output guardrail this is a no-op
// borrow of the empty string.
let mut content_buffer = if output_guardrail.is_some() {
Some(String::new())
} else {
None
Comment on lines +128 to +134
// Add the streaming Model alias to the existing caller's allow-list.
await admin.createApiKey({
key_hash: createHash("sha256")
.update(`${CALLER_PLAINTEXT}-stream`)
.digest("hex"),
allowed_models: ["gr-out-stream-e2e"],
});
…ath skip, Allow e2e

Audit fixes on PR #222:

- HIGH-1: Output `Bypass` verdict at end-of-stream was silently
  dropped from telemetry. Pre-fix the streaming arm only matched
  `Block`; `Bypass { reason }` fell through to `[DONE]` with no
  side effect, while the non-streaming path captures Bypass into
  `usage_events.bypass_reason` so operators can audit which policy
  fail-opened. Added `bypass_reason: String` field to
  `StreamCompletion`, populated on the Bypass arm with first-bypass-
  wins semantics. The on_complete closure merges with the input-
  side `bypass_reason_for_telem` snapshot before emitting telemetry.

- MEDIUM-2: No fast-path skip when the guardrail chain is empty —
  `String` allocated per chunk on every streaming request even for
  guardrail-free deployments. Added `is_empty(&self) -> bool`
  default-method to the `Guardrail` trait (returns `false`,
  preserving safe behavior for custom impls); `GuardrailChain`
  overrides to return `self.guardrails.is_empty()`. The streaming
  call site in `chat.rs::dispatch_streaming` now passes
  `Some(StreamGuardrailContext)` only when the chain is non-empty;
  guardrail-free deployments skip both the per-chunk content
  accumulation and the post-loop synthesized-ChatResponse build.

- MEDIUM-3: E2E did not cover the streaming-Allow path — a
  regression that ALWAYS blocks streaming would have passed the
  existing block-case test. Added a companion case in
  `guardrail-output-e2e.test.ts` with a separate clean upstream +
  Model + caller that emits content NOT containing
  `FORBIDDEN_WORD`. Asserts the wire shape: terminal `[DONE]` IS
  present, no `event: error`, full assistant content reaches the
  caller.

- MEDIUM-1 (client-disconnect bypasses guardrail check) →
  filed as #223. The post-loop check sits after `while let
  Some(item) = upstream.next().await`; client disconnect drops
  the generator at the last yield suspension, so the post-loop
  code never executes and `comp.guardrail_blocked` stays
  `false` even when the buffered content would have blocked.
  Telemetry under-counts disconnects-as-blocks. Out of scope
  for this PR (already a step forward vs pre-fix "every
  streaming request bypasses the guardrail"); recommended fix
  shape is to spawn a detached task from the `CompleteOnDrop`
  Drop impl.

- LOW-1, LOW-2 (cosmetic — model_name field, message-content clone):
  reviewed, deferred. Both are micro-optimizations on a path
  already gated behind the is_empty() fast-path, so they don't
  affect the dominant guardrail-free deployment.

Verification:
- cargo test -p aisix-proxy --lib: 158/158 passing
- cargo test -p aisix-guardrails --lib: 42/42 passing
- cargo clippy --workspace --lib --tests -- -D warnings: clean
- cargo fmt --check: clean
- pnpm tsc --noEmit (e2e): clean
@moonming
moonming merged commit 7e05e6d into main May 10, 2026
5 of 6 checks passed
@moonming
moonming deleted the fix/204-streaming-output-guardrail branch May 10, 2026 12:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: output guardrail does not run on streaming responses (silent bypass via stream:true)

2 participants