fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonmingmoonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes#153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block{ reason } => {
    tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason,"guardrail blocked response");returnErr(with_model(ProxyError::ContentFiltered("response blocked by content policy".into(),)));}

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

CopilotAI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitaiBot commented May 10, 2026

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

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

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

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s)Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 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.

CopilotAI 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 addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

FileDescription
crates/aisix-proxy/src/error.rsChanges ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rsRedacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rsStrengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.tsAdds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

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

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes#153)
When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:
```json
{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}
```
For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.
Redact at the wire boundary, keep rich detail in operator logs.
- `ProxyError::ContentFiltered`'s Display impl changed from
`"content blocked by policy: {0}"` to `"{0}"` so constructors fully
control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
guardrail at L299, output guardrail at L651) now build a generic
static message:
- input: `"request blocked by content policy"`
- output: `"response blocked by content policy"`
and emit the verdict's rich `reason` (which contains the matched
literal and rule type) via `tracing::warn!` for operator debugging.
- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
unit test: replaced the `.contains("forbidden-token")` assertion
(which pinned the leaky behavior) with `!message.contains(...)` plus
an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
to assert the matched literal `"secret-string"` does NOT appear in
ANY field of the wire envelope (full-blob substring check), and that
the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
exercising the user journey through the OpenAI Node SDK against a
live mock upstream that emits a forbidden literal in the assistant
response. Asserts:
- 422 with `error.type === "content_filter"`
- `errorBlob.not.toContain(FORBIDDEN_WORD)`
- upstream WAS hit (output guardrails fire post-dispatch)
- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean
Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
CopilotAI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonmingforce-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374CompareMay 10, 2026 06:01

CopilotAI 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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into mainMay 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for freeto 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 error message echoes the matched forbidden literal back to caller

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonmingmoonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes#153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block{ reason } => {
    tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason,"guardrail blocked response");returnErr(with_model(ProxyError::ContentFiltered("response blocked by content policy".into(),)));}

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

CopilotAI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitaiBot commented May 10, 2026

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

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

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

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s)Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 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.

CopilotAI 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 addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

FileDescription
crates/aisix-proxy/src/error.rsChanges ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rsRedacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rsStrengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.tsAdds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

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

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes#153)
When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:
```json
{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}
```
For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.
Redact at the wire boundary, keep rich detail in operator logs.
- `ProxyError::ContentFiltered`'s Display impl changed from
`"content blocked by policy: {0}"` to `"{0}"` so constructors fully
control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
guardrail at L299, output guardrail at L651) now build a generic
static message:
- input: `"request blocked by content policy"`
- output: `"response blocked by content policy"`
and emit the verdict's rich `reason` (which contains the matched
literal and rule type) via `tracing::warn!` for operator debugging.
- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
unit test: replaced the `.contains("forbidden-token")` assertion
(which pinned the leaky behavior) with `!message.contains(...)` plus
an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
to assert the matched literal `"secret-string"` does NOT appear in
ANY field of the wire envelope (full-blob substring check), and that
the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
exercising the user journey through the OpenAI Node SDK against a
live mock upstream that emits a forbidden literal in the assistant
response. Asserts:
- 422 with `error.type === "content_filter"`
- `errorBlob.not.toContain(FORBIDDEN_WORD)`
- upstream WAS hit (output guardrails fire post-dispatch)
- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean
Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
CopilotAI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonmingforce-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374CompareMay 10, 2026 06:01

CopilotAI 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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into mainMay 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for freeto 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 error message echoes the matched forbidden literal back to caller

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonmingmoonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes#153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block{ reason } => {
    tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason,"guardrail blocked response");returnErr(with_model(ProxyError::ContentFiltered("response blocked by content policy".into(),)));}

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

CopilotAI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitaiBot commented May 10, 2026

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

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

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

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s)Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 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.

CopilotAI 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 addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

FileDescription
crates/aisix-proxy/src/error.rsChanges ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rsRedacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rsStrengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.tsAdds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

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

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes#153)
When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:
```json
{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}
```
For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.
Redact at the wire boundary, keep rich detail in operator logs.
- `ProxyError::ContentFiltered`'s Display impl changed from
`"content blocked by policy: {0}"` to `"{0}"` so constructors fully
control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
guardrail at L299, output guardrail at L651) now build a generic
static message:
- input: `"request blocked by content policy"`
- output: `"response blocked by content policy"`
and emit the verdict's rich `reason` (which contains the matched
literal and rule type) via `tracing::warn!` for operator debugging.
- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
unit test: replaced the `.contains("forbidden-token")` assertion
(which pinned the leaky behavior) with `!message.contains(...)` plus
an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
to assert the matched literal `"secret-string"` does NOT appear in
ANY field of the wire envelope (full-blob substring check), and that
the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
exercising the user journey through the OpenAI Node SDK against a
live mock upstream that emits a forbidden literal in the assistant
response. Asserts:
- 422 with `error.type === "content_filter"`
- `errorBlob.not.toContain(FORBIDDEN_WORD)`
- upstream WAS hit (output guardrails fire post-dispatch)
- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean
Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
CopilotAI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonmingforce-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374CompareMay 10, 2026 06:01

CopilotAI 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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into mainMay 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for freeto 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 error message echoes the matched forbidden literal back to caller

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonmingmoonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes#153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block{ reason } => {
    tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason,"guardrail blocked response");returnErr(with_model(ProxyError::ContentFiltered("response blocked by content policy".into(),)));}

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

CopilotAI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitaiBot commented May 10, 2026

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

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

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

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s)Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 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.

CopilotAI 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 addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

FileDescription
crates/aisix-proxy/src/error.rsChanges ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rsRedacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rsStrengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.tsAdds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

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

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes#153)
When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:
```json
{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}
```
For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.
Redact at the wire boundary, keep rich detail in operator logs.
- `ProxyError::ContentFiltered`'s Display impl changed from
`"content blocked by policy: {0}"` to `"{0}"` so constructors fully
control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
guardrail at L299, output guardrail at L651) now build a generic
static message:
- input: `"request blocked by content policy"`
- output: `"response blocked by content policy"`
and emit the verdict's rich `reason` (which contains the matched
literal and rule type) via `tracing::warn!` for operator debugging.
- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
unit test: replaced the `.contains("forbidden-token")` assertion
(which pinned the leaky behavior) with `!message.contains(...)` plus
an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
to assert the matched literal `"secret-string"` does NOT appear in
ANY field of the wire envelope (full-blob substring check), and that
the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
exercising the user journey through the OpenAI Node SDK against a
live mock upstream that emits a forbidden literal in the assistant
response. Asserts:
- 422 with `error.type === "content_filter"`
- `errorBlob.not.toContain(FORBIDDEN_WORD)`
- upstream WAS hit (output guardrails fire post-dispatch)
- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean
Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
CopilotAI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonmingforce-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374CompareMay 10, 2026 06:01

CopilotAI 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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into mainMay 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for freeto 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 error message echoes the matched forbidden literal back to caller

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonmingmoonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes#153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block{ reason } => {
    tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason,"guardrail blocked response");returnErr(with_model(ProxyError::ContentFiltered("response blocked by content policy".into(),)));}

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

CopilotAI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitaiBot commented May 10, 2026

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

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

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

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s)Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 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.

CopilotAI 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 addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

FileDescription
crates/aisix-proxy/src/error.rsChanges ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rsRedacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rsStrengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.tsAdds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

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

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes#153)
When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:
```json
{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}
```
For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.
Redact at the wire boundary, keep rich detail in operator logs.
- `ProxyError::ContentFiltered`'s Display impl changed from
`"content blocked by policy: {0}"` to `"{0}"` so constructors fully
control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
guardrail at L299, output guardrail at L651) now build a generic
static message:
- input: `"request blocked by content policy"`
- output: `"response blocked by content policy"`
and emit the verdict's rich `reason` (which contains the matched
literal and rule type) via `tracing::warn!` for operator debugging.
- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
unit test: replaced the `.contains("forbidden-token")` assertion
(which pinned the leaky behavior) with `!message.contains(...)` plus
an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
to assert the matched literal `"secret-string"` does NOT appear in
ANY field of the wire envelope (full-blob substring check), and that
the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
exercising the user journey through the OpenAI Node SDK against a
live mock upstream that emits a forbidden literal in the assistant
response. Asserts:
- 422 with `error.type === "content_filter"`
- `errorBlob.not.toContain(FORBIDDEN_WORD)`
- upstream WAS hit (output guardrails fire post-dispatch)
- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean
Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
CopilotAI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonmingforce-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374CompareMay 10, 2026 06:01

CopilotAI 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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into mainMay 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for freeto 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 error message echoes the matched forbidden literal back to caller

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonmingmoonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes#153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block{ reason } => {
    tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason,"guardrail blocked response");returnErr(with_model(ProxyError::ContentFiltered("response blocked by content policy".into(),)));}

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

CopilotAI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitaiBot commented May 10, 2026

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

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

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

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s)Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 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.

CopilotAI 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 addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

FileDescription
crates/aisix-proxy/src/error.rsChanges ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rsRedacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rsStrengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.tsAdds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

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

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes#153)
When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:
```json
{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}
```
For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.
Redact at the wire boundary, keep rich detail in operator logs.
- `ProxyError::ContentFiltered`'s Display impl changed from
`"content blocked by policy: {0}"` to `"{0}"` so constructors fully
control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
guardrail at L299, output guardrail at L651) now build a generic
static message:
- input: `"request blocked by content policy"`
- output: `"response blocked by content policy"`
and emit the verdict's rich `reason` (which contains the matched
literal and rule type) via `tracing::warn!` for operator debugging.
- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
unit test: replaced the `.contains("forbidden-token")` assertion
(which pinned the leaky behavior) with `!message.contains(...)` plus
an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
to assert the matched literal `"secret-string"` does NOT appear in
ANY field of the wire envelope (full-blob substring check), and that
the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
exercising the user journey through the OpenAI Node SDK against a
live mock upstream that emits a forbidden literal in the assistant
response. Asserts:
- 422 with `error.type === "content_filter"`
- `errorBlob.not.toContain(FORBIDDEN_WORD)`
- upstream WAS hit (output guardrails fire post-dispatch)
- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean
Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
CopilotAI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonmingforce-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374CompareMay 10, 2026 06:01

CopilotAI 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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into mainMay 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for freeto 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 error message echoes the matched forbidden literal back to caller

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonmingmoonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes#153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block{ reason } => {
    tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason,"guardrail blocked response");returnErr(with_model(ProxyError::ContentFiltered("response blocked by content policy".into(),)));}

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

CopilotAI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitaiBot commented May 10, 2026

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

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

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

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s)Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 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.

CopilotAI 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 addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

FileDescription
crates/aisix-proxy/src/error.rsChanges ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rsRedacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rsStrengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.tsAdds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

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

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes#153)
When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:
```json
{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}
```
For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.
Redact at the wire boundary, keep rich detail in operator logs.
- `ProxyError::ContentFiltered`'s Display impl changed from
`"content blocked by policy: {0}"` to `"{0}"` so constructors fully
control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
guardrail at L299, output guardrail at L651) now build a generic
static message:
- input: `"request blocked by content policy"`
- output: `"response blocked by content policy"`
and emit the verdict's rich `reason` (which contains the matched
literal and rule type) via `tracing::warn!` for operator debugging.
- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
unit test: replaced the `.contains("forbidden-token")` assertion
(which pinned the leaky behavior) with `!message.contains(...)` plus
an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
to assert the matched literal `"secret-string"` does NOT appear in
ANY field of the wire envelope (full-blob substring check), and that
the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
exercising the user journey through the OpenAI Node SDK against a
live mock upstream that emits a forbidden literal in the assistant
response. Asserts:
- 422 with `error.type === "content_filter"`
- `errorBlob.not.toContain(FORBIDDEN_WORD)`
- upstream WAS hit (output guardrails fire post-dispatch)
- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean
Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
CopilotAI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonmingforce-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374CompareMay 10, 2026 06:01

CopilotAI 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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into mainMay 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for freeto 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 error message echoes the matched forbidden literal back to caller

2 participants

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(proxy): redact matched literal from guardrail-block error envelope - #203

Merged
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak
May 10, 2026
Merged

fix(proxy): redact matched literal from guardrail-block error envelope#203
moonming merged 1 commit into
mainfrom
fix/153-output-guardrail-leak

Conversation

@moonming

@moonmingmoonming commented May 10, 2026

Copy link
Copy Markdown
Member

Closes#153.

Problem

When a kind: "keyword" guardrail (input or output hook_point) blocks a request, the gateway's caller-visible error.message echoes the matched literal verbatim:

{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}

Severity: for output guardrails this is a real bypass — the entire purpose of an output guardrail is to keep forbidden content from reaching the caller, and echoing the matched literal in the error envelope is a partial bypass: anyone who can trigger the rule can extract the model's forbidden output by inspecting error responses. For input guardrails the leak still enables blocklist enumeration (probe with suspect content, read the reflected literal). Labeled vulnerability in the issue.

Fix

Redact at the wire boundary, keep rich detail in operator logs.

  • ProxyError::ContentFiltered's Display impl changes from "content blocked by policy: {0}" to "{0}" so the constructor fully controls the wire string.

  • Both construction sites in crates/aisix-proxy/src/chat.rs (input at L299, output at L651) now build a generic static message and emit the verdict's rich reason (which carries the matched-pattern detail) via tracing::warn!:

    GuardrailVerdict::Block{ reason } => {
    tracing::warn!(guardrail_hook = "output", model = %req.model, reason = %reason,"guardrail blocked response");returnErr(with_model(ProxyError::ContentFiltered("response blocked by content policy".into(),)));}

    Wire-level messages:

    • input: "request blocked by content policy"
    • output: "response blocked by content policy"

Tests

Unit:

  • Updated input_guardrail_block_returns_422_and_skips_upstream: replaced .contains("forbidden-token") (pinned the leaky behavior) with !message.contains("forbidden-token") + exact-match on the redacted string.
  • Strengthened output_guardrail_block_returns_422_after_upstream_runs: asserts the matched literal "secret-string" does NOT appear in ANY field of the wire envelope (full-blob substring check), and that the message exactly equals the redacted string.

E2E (regression): restored tests/e2e/src/cases/guardrail-output-e2e.test.ts from the held-back queue. Configures an output keyword guardrail with value: "leakedsecret", sends an innocent prompt, has the mock upstream emit a response containing the forbidden literal, and asserts:

  • 422 with error.type === "content_filter"
  • JSON.stringify(caught.error).not.toContain(FORBIDDEN_WORD)
  • caught.message.not.toContain(FORBIDDEN_WORD)
  • upstream.receivedRequests.length increased (output guardrails run post-dispatch)

Verification

Out of scope (filed as #199, #204)

Audit follow-ups (post-push, commit 6d3a9c6)

Independent audit on the initial push (commit 041fcff) surfaced four findings; resolved or justified:

  • HIGH-1 → fixed: rebased onto current main (post-fix(proxy): record real token counts on streamed + output-blocked chats #196). PR fix(proxy): record real token counts on streamed + output-blocked chats #196 changed the output-block error tuple from Err((Option<String>, ProxyError)) to Err((Option<String>, Option<UpstreamCharge>, ProxyError)) to plumb upstream-billed token counts. The redaction now lands inside the new tuple shape at crates/aisix-proxy/src/chat.rs:823 while keeping the UpstreamCharge capture intact. Without this rebase, my initial hunk would not have applied cleanly and the leak would have silently re-introduced post-merge.

  • HIGH-2 → fixed: tightened tests/e2e/src/cases/guardrail-keyword-e2e.test.ts (input-side e2e) to assert the matched literal is NOT in caught.error JSON or caught.message — symmetric to the new output-side e2e. Without this, a regression that re-introduced leakage on the input path would have passed silently. Replaced .toMatchObject({ status, error: { type } }) with explicit try/catch + class+envelope assertions.

  • MEDIUM-1 → filed as bug: output guardrail does not run on streaming responses (silent bypass via stream:true) #204: streaming output path has zero output-guardrail coverage (see "Out of scope" above).

  • MEDIUM-2 → operator note: the PR moves the matched literal from HTTP error body to a tracing::warn!(reason = %reason, ...) field. If a deployment configures tracing-opentelemetry to export warn-level events to a backend visible to callers (e.g. shared OTel tenants, or admin APIs that surface recent log lines to non-admin viewers), the literal could reappear caller-visible. Operator guidance: ensure tracing exporters are server-only; warn-level guardrail-block events should not be forwarded to caller-visible channels. Worth covering in deployment docs.

  • LOW-1 → noted: ProxyError::ContentFiltered's Display impl changed from "content blocked by policy: {0}" to "{0}". Customers substring-matching on the OLD prefix "content blocked by policy" will silently fail to detect blocks. Status (422) and error.type (content_filter) are unchanged, so programmatic clients keying off the OpenAI taxonomy are unaffected. The redacted strings ("request blocked by content policy", "response blocked by content policy") are reasonable replacements. Note for release notes.

References

Summary by CodeRabbit

  • Bug Fixes

    • Content policy blocks now return redacted, generic error messages (no matched-pattern details) for both input and output validations; visible error text standardized.
  • Tests

    • Added E2E coverage for output guardrail behavior and strengthened tests to assert blocked responses are redacted and have expected error types/statuses.
  • Documentation

    • Clarified that caller-visible error messages must not include matched-pattern details; such details are reserved for operator logs.

Review Change Stack

CopilotAI review requested due to automatic review settings May 10, 2026 05:51
@coderabbitai

coderabbitaiBot commented May 10, 2026

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

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 3c4e8233-78ad-469f-967f-29587fe3c32c

📥 Commits

Reviewing files that changed from the base of the PR and between 041fcff and 6331374.

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

📝 Walkthrough

Walkthrough

This PR redacts guardrail-matched patterns from client-facing error messages to prevent policy bypass via echoed literals. The error type definition is simplified, input and output guardrail handlers log matched details to tracing while returning generic redacted messages, unit tests verify redaction is enforced, and a new E2E test validates the output guardrail path end-to-end.

Changes

Guardrail Pattern Redaction

Layer / File(s)Summary
Error Type Definition
crates/aisix-proxy/src/error.rs
ProxyError::ContentFiltered formatting changed to #[error("{0}")], requiring callers to pass pre-redacted messages. Documentation clarifies that matched-pattern detail must be reserved for operator logs only.
Input and Output Guardrail Blocks
crates/aisix-proxy/src/chat.rs
Input-block handler (lines 396–410) and output-block handler (lines 822–839) now log the matched reason to tracing::warn! but return ProxyError::ContentFiltered with a fixed generic message ("request blocked by content policy" / "response blocked by content policy").
Unit Test Redaction Assertions
crates/aisix-proxy/src/lib.rs
Input-block test (lines 1619–1630) and output-block test (lines 1740–1760) assertions now verify that error.message excludes the blocked literal, equals the redacted string, and the full envelope contains no leakage of the pattern.
Output Guardrail E2E Test
tests/e2e/src/cases/guardrail-output-e2e.test.ts
New test suite configures a mocked upstream returning forbidden content, registers an output-hook guardrail, and verifies the forbidden literal is absent from both the serialized error envelope and message, the error type is content_filter, and upstream is called exactly once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 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.

CopilotAI 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 addresses a security vulnerability where keyword guardrails (especially hook_point: "output") could leak the matched forbidden literal back to callers via the OpenAI-shaped error.message. The fix ensures the wire-level error message is redacted while preserving the detailed match reason in operator logs.

Changes:

  • Redacted ProxyError::ContentFiltered display output so call sites fully control the caller-visible message.
  • Updated input/output guardrail block paths to log the detailed verdict reason via tracing::warn! while returning a generic, non-leaking error message.
  • Added/updated unit + e2e regression tests to assert the forbidden literal never appears anywhere in the caller-visible error envelope.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

FileDescription
crates/aisix-proxy/src/error.rsChanges ContentFiltered display to avoid prefixing and documents the redaction requirement.
crates/aisix-proxy/src/chat.rsRedacts guardrail-block error messages on input/output paths and logs detailed reasons to tracing.
crates/aisix-proxy/src/lib.rsStrengthens unit tests to assert matched literals are not present and messages match the redacted strings.
tests/e2e/src/cases/guardrail-output-e2e.test.tsAdds an e2e regression test for output keyword guardrails ensuring no forbidden literal leaks in error responses.

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

Comment on lines +81 to +83
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
/// (`"request blocked by content policy"` /
closes#153)
When a `kind: "keyword"` guardrail blocked a request or response, the
gateway's caller-visible `error.message` (OpenAI envelope) included the
matched literal verbatim:
```json
{
"error": {
"message": "content blocked by policy: output blocked by literal \"leakedsecret\"",
"type": "content_filter"
}
}
```
For OUTPUT guardrails this is a real bypass — the whole point of an
output guardrail is to keep forbidden content from reaching the caller,
and echoing the matched literal in the error message defeats that.
Anyone who can trigger the rule can extract the model's forbidden
output via error responses. For INPUT guardrails the leak also enables
blocklist enumeration: probing with suspect content and inspecting the
reflected literal lets a caller learn the policy's patterns.
Redact at the wire boundary, keep rich detail in operator logs.
- `ProxyError::ContentFiltered`'s Display impl changed from
`"content blocked by policy: {0}"` to `"{0}"` so constructors fully
control the wire-level string.
- Both construction sites in `crates/aisix-proxy/src/chat.rs` (input
guardrail at L299, output guardrail at L651) now build a generic
static message:
- input: `"request blocked by content policy"`
- output: `"response blocked by content policy"`
and emit the verdict's rich `reason` (which contains the matched
literal and rule type) via `tracing::warn!` for operator debugging.
- Updated existing `input_guardrail_block_returns_422_and_skips_upstream`
unit test: replaced the `.contains("forbidden-token")` assertion
(which pinned the leaky behavior) with `!message.contains(...)` plus
an exact-match check on the redacted string.
- Strengthened `output_guardrail_block_returns_422_after_upstream_runs`
to assert the matched literal `"secret-string"` does NOT appear in
ANY field of the wire envelope (full-blob substring check), and that
the message exactly equals the redacted string.
- Added e2e regression `tests/e2e/src/cases/guardrail-output-e2e.test.ts`
exercising the user journey through the OpenAI Node SDK against a
live mock upstream that emits a forbidden literal in the assistant
response. Asserts:
- 422 with `error.type === "content_filter"`
- `errorBlob.not.toContain(FORBIDDEN_WORD)`
- upstream WAS hit (output guardrails fire post-dispatch)
- `cargo test -p aisix-proxy --lib`: 138/138 passing
- `cargo clippy -p aisix-proxy --lib --tests -- -D warnings`: clean
- `cargo fmt --check`: clean
- `pnpm tsc --noEmit` (e2e): clean
Refs: #199 (related: BridgeError Display also leaks upstream-message
bleed-through into `error.message` system-wide; out of scope here, but
fix shape is similar — sanitize at proxy boundary, keep rich detail in
tracing).
CopilotAI review requested due to automatic review settings May 10, 2026 06:01
@moonming
moonmingforce-pushed the fix/153-output-guardrail-leak branch from 6d3a9c6 to 6331374CompareMay 10, 2026 06:01

CopilotAI 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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +81 to +82
/// Constructors at `chat.rs::route_chat_completions` and
/// `chat.rs::dispatch_and_render` build a redacted public message
@moonming
moonming merged commit b5f3491 into mainMay 10, 2026
9 of 10 checks passed
@moonming
moonming deleted the fix/153-output-guardrail-leak branch May 10, 2026 06:04
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
moonming added a commit that referenced this pull request May 14, 2026
- quickstart/self-hosted: document the /livez liveness route and plain-text
body (per #257); add Cleanup
- quickstart/openai-sdk: switch to .mjs so \`node\` runs the example without
a TypeScript loader
- quickstart/first-model-first-key-first-request: add production-credentials
warning, 401/403 verification step using the real proxy error envelope,
Cleanup, and a per-provider api_base callout
- integration/openai-compatible-api: add mermaid diagram of the request
path; route list reflects the /livez rename
- integration/errors-and-retries: replace placeholder error.type strings
with the real ProxyError mapping; add 413 RequestTooLarge row; note the
admin {error_msg} envelope distinction
- configuration/provider-keys: warn about plaintext secret storage; replace
the inaccurate OpenAI api_base normalization claim with a per-provider
truth table (refs #270)
- operations/health-checks: rewrite for the /livez liveness contract plus
the /admin/v1/health per-model shape (per #256 + #257)
- reference/proxy-api-reference: /livez instead of /health in the route
list
- tutorials/build-a-virtual-model-with-failover: rewrite end-to-end against
routing-strategies-e2e (deliberately break primary, observe cooldown)
- tutorials/enable-response-caching: rewrite against cache-policy-e2e
(x-aisix-cache miss then hit, different prompt misses again)
- tutorials/add-keyword-guardrails: rewrite against guardrail-keyword-e2e
(422 content_filter; no-leak message contract per #203)
- tutorials/openai-client-to-anthropic-upstream: rewrite against
anthropic-upstream-e2e; document bare-host api_base for Anthropic
Every command, field, header, and error code was cross-checked against the
relevant crate or e2e test on this branch. No mock data; nothing speculative.
Sign up for freeto 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 error message echoes the matched forbidden literal back to caller

2 participants

@moonming