fix(guardrails): run the input chain on every request that reaches an upstream - #1064
Merged
Conversation
… upstream A request carrying no scannable text was reaching upstreams without a guardrail verdict. Release QA found two instances on v0.11.0-rc.5; both reproduce on rc.4, and an audit of the whole routed surface found the same defect in four more places. The shared mistake is treating "nothing to scan" as "nothing to decide". A guardrail that matches text has legitimately found nothing; a guardrail that decides about the CALL — a `kind: custom` policy script — has a verdict either way, and only it can tell the two apart. That judgment belongs to the guardrail, never to the call site. Fixed, in the order a request meets them: * `custom.rs` returned Allow before running the script when every message was empty (and likewise on an empty response). * `redact::moderate_body` and `mcp::moderate_selected_segments` returned early when the collect walk found zero slots, so on the chat / messages / responses / completions / MCP families a segment-moderating member — which is every custom script — was never consulted at all. * `/v1/audio/transcriptions`, `/v1/audio/translations` and `/v1/images/edits` ran the chain only `if !prompt_messages.is_empty()`, i.e. never on the ordinary shape of those endpoints. * `/v1/messages/count_tokens` ran no chain at all. * `/a2a/:agent` ran no chain at all. * `/v1/messages` skipped the check when its Anthropic parse failed, making the guardrail only as complete as the parser. No new provider round-trips: every remote kind (bedrock, lakera, presidio, aliyun) already short-circuits empty input on its own, so an argument-less MCP call costs one local sandbox run and no network call. `count_tokens` gets the input hook but not the output hook. Its response is an integer the provider generated nothing for, so there is nothing to moderate on the way back; its REQUEST ships the caller's whole `system` + `messages` + `tools` payload to the provider, which is exactly what a PII or exfiltration policy exists to govern. The prior exemption argued the payload gets scanned on the real `/v1/messages` call, but nothing obliges a caller to make one. `crates/aisix-proxy/src/guardrail_coverage.rs` is the anti-drift half. It parses the routing table out of `build_router`'s own source, requires every mounted surface to carry an explicit posture with a reason, and drives each enforced one through the real router against a guardrail that blocks unconditionally. Against the unfixed code it reports 16 of 17 surfaces reaching the upstream.
|
Warning Review limit reached
On-demand reviews are free for the next 24 days. After that, they cost $0.25 per reviewed file. Or wait 37 minutes for your next included review. View limit detailsLimit details: You’ve used the included review currently available. Your 60 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (12)
Comment |
jarvis9443
added a commit
that referenced
this pull request
Aug 28, 2026
#1064 wired the input hook into `/a2a/:agent`, which lands on the same gap this branch closes elsewhere: the refusal emits one usage event and left `guardrail_blocked` defaulted, so the Blocked view could never see it. `/a2a` emits exactly one event per call, so that row is the only place the refusal can appear at all. Its token counters stay as they are. They are the gateway's own reading of the request words — filled before the chain runs, flagged `usage_estimated`, never charged — so unlike the LLM surfaces a refused A2A call is not expected to report zero, and the census records that exemption rather than asserting past it.
This was referenced Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release QA on
v0.11.0-rc.5found two ways a request could reach an upstream without an input guardrail verdict. Both reproduce on rc.4, so they are pre-existing rather than a regression in this release. Auditing the rest of the routed surface turned up four more instances of the same defect, so they are all fixed here.The shared mistake
Every one of these treats "nothing to scan" as "nothing to decide". That confusion is only invisible while every guardrail is a text matcher. A
kind: custompolicy script is not: it can block onctx.model, on a secret-backed lookup, or unconditionally, and only the guardrail itself can tell "I matched nothing" apart from "I was never asked". The call site had been making that call on the guardrail's behalf, and getting it wrong.So the rule is now: the call site always consults the chain; only a guardrail kind may decide it needs text. That is safe for cost because every remote kind already short-circuits empty input on its own —
bedrock(texts.iter().all(is_empty)),lakera(returns beforecall_api),presidio(analyzereturns before the HTTP post),aliyun_ai_guardrail(joined.is_empty()). An argument-less MCP call therefore costs one local QuickJS sandbox run and zero provider round-trips.The two reported bypasses
MCP
tools/callwith emptyargumentsexecuted the tool.mcp::moderate_selected_segmentscollects the string leaves underparams.argumentsand returnedKeepwhen it found none. Akind: customguardrail is a segment moderator, so the non-segment fold above it (check_input_non_segment_observed) deliberately skips it — the segment pass is where it was supposed to be consulted, and that pass bailed out before consulting anything./v1/messages/count_tokensran no chain at all. It now runs the input hook and not the output hook, and the asymmetry is the point. The response is{"input_tokens": <int>}— the provider generated nothing, so an output guardrail has nothing to moderate. The request ships the caller's entiresystem+messages+toolspayload to the provider, which is exactly the transmission a PII / DLP / exfiltration policy exists to govern. The original exemption argued that the same payload is scanned when the caller issues the real/v1/messagescall; nothing obliges a caller to ever issue one, socount_tokensalone was a complete egress channel. Mask-action rules rewrite the body here too, which also keeps the answer honest —/v1/messagesmasks the same spans, so the count now describes the body the gateway would really send.The rest of the family
/v1/chat/completions/v1/completions/v1/responses/v1/messages/v1/messages/count_tokens/v1/embeddings/v1/rerank/v1/images/generations/v1/images/editspromptpart/v1/audio/transcriptionspromptfield (the ordinary shape)/v1/audio/translations/v1/audio/speech/v1/videos/v1/realtime/v1/files,/v1/batches,/v1/fine_tuning/jobstools/callargumentshad no string leaves/a2a/:agentRoutes with no upstream-bound caller content —
/livez,/readyz,/v1/models, the.well-knownmetadata, and the by-id reads and cancels under videos / files / batches / fine-tuning — legitimately run no chain. Each now records that decision and its reason in the census table.Two entries are behaviour changes rather than repairs of a skipped check, and both want a release note:
/a2a/:agentnow screens message text on the input hook. Scoping resolves through env / api-key / team;/a2acarries no model or MCP-server id, so an attachment on one of those scopes still does not apply here. The output hook stays unwired — an A2A answer arrives as artifacts and status updates across a stream that can run for hours, and moderating it needs the streamed-output machinery the LLM surfaces have./v1/messageswith a body the Anthropic parser rejects now returns422+unscannable_bodyinstead of forwarding it, and only when a guardrail chain is attached. Every such body is one the provider itself rejects, so this changes the error rather than the outcome — but it stops the guardrail being only as complete as the parser, which is a property that decays every time a provider adds a shape./mcpalready took this arm.Keeping the family from drifting again
crates/aisix-proxy/src/guardrail_coverage.rsis a census, and nothing in it is hand-listed — that is deliberate, since these bugs survived alongside a test that restated a list of endpoints instead of deriving one.build_router's own source. Mount a route without classifying it and the test fails, naming the route and telling the author what to decide.Enforcedmust carry a reason, and the test reads it.Enforcedsurface must carry a request fixture, and each is driven through the real router against akind: customguardrail that blocks unconditionally. Bodies are deliberately contentless wherever the wire shape allows — no prompt,"arguments": {}, empty message text — because that is the shape every bug in this class hid behind.Against the unfixed code the census reports 16 of the 17 enforced surfaces reaching the upstream; the one that blocked is
/v1/videos, whose schema rejects an empty prompt so its fixture is the only one carrying text.Two counterweights keep it honest.
fixtures_do_not_self_refuse_without_a_guardraildrives the same fixtures with no guardrail configured, so the pass cannot be coming from unrelated error text.a_text_matching_guardrail_leaves_textless_requests_alonedrives them against akeywordrule that matches nothing and requires all of them through — the fix must not turn "no text" into a blanket block.Tests
tests/e2e/src/cases/guardrail-textless-request-e2e.test.ts— real binary + etcd + a real MCP upstream, driving the caller-visible contract of each reported bypass and asserting the recorded upstream never saw the request. Fails 3/4 before the fix, passes 4/4 after; the fourth is the keyword negative control, which passes in both states.guardrail_coverage.rs— 4 census tests, described above.custom.rs— 4 unit tests pinning the empty-text semantics on both hooks and both the check and segment entry points, including that a text-matching script still allows.Full DP e2e (223 files, 692 tests) green.
cargo test --workspace,cargo fmt --all -- --checkandcargo clippy --workspace --all-targets -- -D warningsclean.Docs
The
api7/docsguardrail pages need a paired bilingual update; the release owner has the specifics.