diff --git a/crates/aisix-guardrails/AGENTS.md b/crates/aisix-guardrails/AGENTS.md index 9d8944c0..6019fcc6 100644 --- a/crates/aisix-guardrails/AGENTS.md +++ b/crates/aisix-guardrails/AGENTS.md @@ -55,6 +55,26 @@ shapes, both carrying the same bounded per-kind failure tag and neither allowed to carry matched content (#153): an explicitly fail-open row emits `Bypass`, a fail-closed row emits `Block { unavailable: Some(tag) }`. +**Carry that tag all the way to the caller.** A fail-closed availability +block and a content block are the same 422 with the same `error.type`, so +the tag is the only thing that separates "your policy fired" from "your +guardrail is broken" — drop it and an operator debugs a policy that is +fine while their traffic is refused. Every block site in `aisix-proxy` +therefore builds its message through `error::guardrail_block_message` / +`guardrail_block_error` and passes the verdict's `unavailable` through; +the tag also lands on `error.code = "guardrail_unavailable"`, the audit +hit's `blocked_unavailable`, and the histogram's `error_type`. A refusal +the proxy raises on a guardrail's behalf (a hold-back cap, a failed mask +splice) carries one too — see `error::TAG_*`. + +**Give each failure cause its own tag.** Tags are what a dashboard shows, +so collapsing distinct operator mistakes into one catch-all costs the +operator the diagnosis: `custom_unknown_action` (a word we do not know) +and `custom_no_verdict` (no decision at all) need different fixes, and +neither is `custom_script_error` (their service is down). And a verdict +we cannot read is always a FAILURE, never an Allow — reading silence as +consent is the open door `fail_open: false` exists to close. + **`enforcement_mode: monitor` is unconditional.** A monitored row never blocks, for any reason — not a content match, not a provider outage, not a failure policy. Do not add an exception: the value of the mode is that diff --git a/crates/aisix-guardrails/src/custom.rs b/crates/aisix-guardrails/src/custom.rs index 22f00141..c74ff066 100644 --- a/crates/aisix-guardrails/src/custom.rs +++ b/crates/aisix-guardrails/src/custom.rs @@ -13,11 +13,24 @@ //! ``` //! //! `ctx` carries `{ hook, text, segments, messages, model, secrets }`. A -//! verdict is `{ action: "none" }`, `{ action: "block", reason, -//! reason_code }`, or `{ action: "mask", segments, counts }` where -//! `segments` is positionally aligned with `ctx.segments`. A hook whose -//! function the module does not export is an Allow, so one script may -//! cover one direction. +//! verdict is `{ action: "none" }` (or the synonym `{ action: "allow" }`), +//! `{ action: "block", reason, reason_code }`, or `{ action: "mask", +//! segments, counts }` where `segments` is positionally aligned with +//! `ctx.segments`. A hook whose function the module does not export is an +//! Allow, so one script may cover one direction. +//! +//! The vocabulary is CLOSED — [`ACTION_VOCABULARY`] is all of it. A hook +//! that returns anything else, or returns nothing, has not screened the +//! content, so it is a script FAULT and not a decision: `fail_open` +//! settles what happens to the request, and the failure is reported as a +//! fault everywhere an operator looks. It never wears a content block's +//! clothes — not in the caller's error envelope (which says the guardrail +//! could not evaluate the request and carries `error.code = +//! "guardrail_unavailable"`), not in the audit hit (`blocked_unavailable`), +//! and not in the `error_type` on the latency histogram. An operator +//! reading any one of those three can tell "my script is broken" from "my +//! policy is firing", which is the whole point: the two are otherwise the +//! same 422 and the same universal-block symptom. //! //! A script can allow, block, OR rewrite. Rewriting rides the same async //! segment pass the built-in remote redacting kinds use @@ -59,7 +72,7 @@ //! //! | Outcome | `fail_open` | Verdict | //! |----------------------------------------|-------------|------------------------------------| -//! | returns `{action:"none"}` | n/a | Allow | +//! | returns `{action:"none"}` / `"allow"` | n/a | Allow | //! | hook function not exported | n/a | Allow | //! | returns `{action:"block"}` | n/a | Block { reason } | //! | returns `{action:"mask"}` | n/a | Allow with rewritten segments | @@ -67,9 +80,11 @@ //! | `mask` with a mismatched slot count | true | Bypass { "custom_bad_verdict" } | //! | wall-clock budget elapsed | true | Bypass { "custom_timeout" } | //! | script threw | true | Bypass { "custom_script_error" } | +//! | returned an action outside the vocabulary | true | Bypass { "custom_unknown_action" } | +//! | returned nothing / no `action` field | true | Bypass { "custom_no_verdict" } | //! | returned a shape that is not a verdict | true | Bypass { "custom_bad_verdict" } | //! | engine could not start | true | Bypass { "custom_engine_error" } | -//! | any failure | false | Block { "custom script unavailable …" } | +//! | any failure | false | Block { unavailable: } | use std::collections::BTreeMap; use std::sync::Arc; @@ -86,6 +101,14 @@ use crate::{Guardrail, GuardrailVerdict, SegmentsOutcome, StreamOutputPolicy}; /// a thrown error carries, so keep it recognisable to the operator. const MODULE_NAME: &str = "guardrail.js"; +/// The complete set of `action` values a verdict may carry, as one string. +/// +/// It rides every diagnostic a verdict mistake produces, because the whole +/// failure mode is an operator who does not know what the vocabulary is: +/// a log line that says "unknown action" and stops has told them their +/// script is wrong without telling them what right looks like. +const ACTION_VOCABULARY: &str = "none | allow | block | mask"; + /// Share of the sandbox heap one fetch body may occupy. A body has to fit /// alongside the host JSON that carries it, the `JSON.parse` result, and /// the response object built from it — so spending the whole heap on the @@ -426,9 +449,12 @@ impl CustomGuardrail { None => { tracing::warn!( row = %row_name, - "custom guardrail returned undefined instead of a verdict", + expected = ACTION_VOCABULARY, + "custom guardrail hook returned nothing instead of a verdict; \ + the request is NOT screened and will be refused unless \ + fail_open is set", ); - Err(ScriptFailure::BadVerdict) + Err(ScriptFailure::NoVerdict) } } }) @@ -442,16 +468,18 @@ impl CustomGuardrail { /// Translate the script's return value into an outcome. fn parse_verdict(&self, json: &str) -> Result { - let parsed: ScriptVerdict = serde_json::from_str(json).map_err(|e| { - tracing::warn!( - row = %self.row_name, - error = %e, - "custom guardrail returned a value that is not a verdict object", - ); - ScriptFailure::BadVerdict - })?; + let parsed: ScriptVerdict = match serde_json::from_str(json) { + Ok(parsed) => parsed, + Err(e) => return Err(self.malformed_verdict(json, &e)), + }; match parsed.action.as_str() { - "none" => Ok(ScriptOutcome::Allow), + // `none` is the original spelling and `allow` the word most + // operators reach for first; both mean the same decision, and + // refusing one of them buys nothing but a support ticket. The + // vocabulary stays CLOSED at these four — an open-ended synonym + // list can never be complete, so anything else is reported as + // the authoring mistake it is rather than silently guessed at. + "none" | "allow" => Ok(ScriptOutcome::Allow), "block" => { // Both fields are operator-authored and land in ops logs // only — `Block.reason` never reaches the wire envelope @@ -471,7 +499,8 @@ impl CustomGuardrail { let Some(segments) = parsed.segments else { tracing::warn!( row = %self.row_name, - "custom guardrail asked to mask without returning segments", + "custom guardrail asked to mask without returning segments; the \ + request is NOT screened and will be refused unless fail_open is set", ); return Err(ScriptFailure::BadVerdict); }; @@ -484,13 +513,47 @@ impl CustomGuardrail { tracing::warn!( row = %self.row_name, action = %other, - "custom guardrail returned an unknown action", + expected = ACTION_VOCABULARY, + "custom guardrail returned an unknown action; the request is NOT \ + screened and will be refused unless fail_open is set", ); - Err(ScriptFailure::BadVerdict) + Err(ScriptFailure::UnknownAction) } } } + /// Classify a return value `serde` could not read as a verdict. + /// + /// "Decided nothing" and "returned a shape that is not a verdict" are + /// different authoring mistakes with different fixes, so they get + /// different tags — `{}` / `null` is a script that fell off a path, + /// while `"block"` / `42` is a script written against the wrong + /// contract. Both stay failures: a hook that did not state a decision + /// has not screened the content, and reading silence as consent is + /// exactly the open door `fail_open: false` exists to close. + fn malformed_verdict(&self, json: &str, err: &serde_json::Error) -> ScriptFailure { + let value: Option = serde_json::from_str(json).ok(); + let (failure, detail) = match &value { + None | Some(serde_json::Value::Null) => (ScriptFailure::NoVerdict, "no value"), + Some(serde_json::Value::Object(map)) if !map.contains_key("action") => { + (ScriptFailure::NoVerdict, "object without an `action` field") + } + Some(serde_json::Value::Object(_)) => { + (ScriptFailure::BadVerdict, "`action` is not a string") + } + Some(_) => (ScriptFailure::BadVerdict, "not an object"), + }; + tracing::warn!( + row = %self.row_name, + error = %err, + detail = detail, + expected = ACTION_VOCABULARY, + "custom guardrail returned a value that is not a verdict object; the \ + request is NOT screened and will be refused unless fail_open is set", + ); + failure + } + fn handle_failure(&self, failure: ScriptFailure, fail_open: bool) -> GuardrailVerdict { let tag = failure.bypass_tag(); tracing::warn!( @@ -1116,15 +1179,34 @@ enum ScriptOutcome { } /// Failure cause buckets. `bypass_tag()` maps to the strings stored in -/// `usage_events.guardrail_bypassed_reason` — changing them is a breaking -/// change for operators who filter on these values. +/// `usage_events.guardrail_bypassed_reason`, the `error_type` label on +/// `aisix_guardrail_latency_seconds`, and — since AISIX-Cloud#1365 — the +/// `error_type` of a fail-closed `blocked_unavailable` audit hit. Changing +/// them is a breaking change for operators who filter on these values. +/// +/// The three script-authoring mistakes are separate buckets on purpose. +/// They collapse to one caller-facing outcome (fail-closed refusal), so +/// the tag is the only thing that tells an operator watching a dashboard +/// WHICH mistake their script is making — "you returned an action I do not +/// know" and "you returned nothing at all" need different fixes, and +/// neither is "your screening service is down". #[derive(Debug)] enum ScriptFailure { /// The wall-clock budget elapsed, or the interrupt handler fired. Timeout, /// The script raised, or the module body did. Threw, - /// The script returned something that is not a verdict object. + /// The hook stated a decision, but not one this kind understands — + /// `{action: "permit"}`. Almost always a vocabulary slip. + UnknownAction, + /// The hook stated no decision at all: no `return`, `undefined`, + /// `null`, or an object with no `action` field. Distinct from + /// [`Self::UnknownAction`] because the fix is different — the script + /// fell off an unhandled path rather than typing the wrong word. + NoVerdict, + /// The hook returned something that is not a verdict object at all (a + /// string, a number, an array), or a verdict this kind cannot carry + /// out (a `mask` with no segments, or the wrong number of them). BadVerdict, /// The engine could not be started, or the host surface not installed. Engine, @@ -1135,6 +1217,8 @@ impl ScriptFailure { match self { Self::Timeout => "custom_timeout", Self::Threw => "custom_script_error", + Self::UnknownAction => "custom_unknown_action", + Self::NoVerdict => "custom_no_verdict", Self::BadVerdict => "custom_bad_verdict", Self::Engine => "custom_engine_error", } @@ -1280,7 +1364,92 @@ mod tests { let GuardrailVerdict::Block { unavailable, .. } = verdict else { panic!("expected Block, got {verdict:?}"); }; - assert_eq!(unavailable.as_deref(), Some("custom_bad_verdict")); + // Its own tag, not the catch-all: the operator's mistake is a word, + // and the tag is what tells them so from a dashboard. + assert_eq!(unavailable.as_deref(), Some("custom_unknown_action")); + } + + #[tokio::test] + async fn allow_is_accepted_as_a_synonym_of_none() { + // The word an operator reaches for first. It used to land in the + // unknown-action arm, which — under the fail-closed default — meant + // a one-word slip refused EVERY request with the same 422 a + // correctly-firing policy produces. + let cfg = config("export function checkInput() { return { action: 'allow' }; }"); + let verdict = guardrail(&cfg, false).check_input(&request("hello")).await; + assert!( + matches!(verdict, GuardrailVerdict::Allow), + "expected Allow, got {verdict:?}" + ); + } + + #[tokio::test] + async fn the_action_vocabulary_stays_closed() { + // `allow` is a synonym, not the start of a synonym list: anything + // else is still reported as the authoring mistake it is, rather + // than guessed at. + for word in ["pass", "ok", "permit", "deny", "reject", "safe"] { + let cfg = config(&format!( + "export function checkInput() {{ return {{ action: '{word}' }}; }}" + )); + let verdict = guardrail(&cfg, false).check_input(&request("hello")).await; + let GuardrailVerdict::Block { unavailable, .. } = verdict else { + panic!("expected Block for {word}, got {verdict:?}"); + }; + assert_eq!( + unavailable.as_deref(), + Some("custom_unknown_action"), + "{word}" + ); + } + } + + #[tokio::test] + async fn a_hook_that_decides_nothing_is_its_own_failure_mode() { + // All four are "the script stated no decision", which is a + // different fix from "the script typed the wrong word" — so it is + // a different tag, even though both refuse the request. + for body in [ + "return;", + "", + "return null;", + "return {};", + "return { reason: 'oops' };", + ] { + let cfg = config(&format!("export function checkInput() {{ {body} }}")); + let verdict = guardrail(&cfg, false).check_input(&request("hello")).await; + let GuardrailVerdict::Block { unavailable, .. } = verdict else { + panic!("expected Block for {body:?}, got {verdict:?}"); + }; + assert_eq!( + unavailable.as_deref(), + Some("custom_no_verdict"), + "{body:?}" + ); + } + } + + #[tokio::test] + async fn a_script_fault_still_fails_closed_and_never_reads_as_an_allow() { + // The fix is about how the refusal is REPORTED, not about whether + // it happens: a hook that produced no usable verdict has screened + // nothing, and reading that as consent is the open door + // `fail_open: false` exists to close. + for body in ["return { action: 'allowed' };", "return;", "return 'none';"] { + let cfg = config(&format!("export function checkInput() {{ {body} }}")); + let verdict = guardrail(&cfg, false).check_input(&request("hello")).await; + assert!( + matches!(verdict, GuardrailVerdict::Block { .. }), + "{body:?} must refuse, got {verdict:?}" + ); + // ...and it is still a fail-OPEN row's decision to let it pass, + // reported as a bypass rather than as an allow. + let verdict = guardrail(&cfg, true).check_input(&request("hello")).await; + assert!( + matches!(verdict, GuardrailVerdict::Bypass { .. }), + "{body:?} must bypass when fail_open, got {verdict:?}" + ); + } } #[tokio::test] diff --git a/crates/aisix-obs/src/metrics.rs b/crates/aisix-obs/src/metrics.rs index 5a808712..47a49efe 100644 --- a/crates/aisix-obs/src/metrics.rs +++ b/crates/aisix-obs/src/metrics.rs @@ -240,9 +240,14 @@ pub const M_AUTH_DECISIONS_TOTAL: &str = "aisix_auth_decisions_total"; /// - `phase`: `input` / `output`. /// - `result`: `allowed` / `blocked` / `masked` / `bypassed` (remote /// failure + fail-open) / `would_block` / `would_mask` (monitor mode). -/// - `error_type`: bounded failure tag (e.g. `lakera_timeout`) when -/// `result="bypassed"`, else `none`. Fail-closed failures surface as -/// `blocked` (the timeout budget shows up in the latency distribution). +/// - `error_type`: bounded failure tag (e.g. `lakera_timeout`, +/// `custom_unknown_action`) whenever the guardrail could not EVALUATE +/// the content, else `none`. It is populated on `result="bypassed"` +/// (fail-open) and equally on the `result="blocked"` a fail-CLOSED row +/// produces for the same cause (AISIX-Cloud#1365) — `result` stays +/// `blocked` there so a shipped alert on it keeps counting, and +/// `error_type != "none"` is what separates "this content violated a +/// policy" from "this guardrail is broken or its provider is down". /// /// The `_count` series doubles as a per-guardrail execution counter, so /// there is no separate `aisix_guardrail_requests_total` (LiteLLM's diff --git a/crates/aisix-proxy/src/attempt.rs b/crates/aisix-proxy/src/attempt.rs index 3d21ecb6..35a25fc6 100644 --- a/crates/aisix-proxy/src/attempt.rs +++ b/crates/aisix-proxy/src/attempt.rs @@ -314,7 +314,7 @@ pub(crate) fn attempt_error_from_proxy(err: &ProxyError) -> (String, String) { pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool { match err { ProxyError::Bridge(be) => be.reached_upstream(), - ProxyError::ContentFiltered(_) => true, + ProxyError::ContentFiltered { .. } => true, ProxyError::MissingAuth | ProxyError::MissingRouteAuthHeader(_) | ProxyError::InvalidApiKey @@ -400,9 +400,10 @@ mod tests { // Only the output hook can fire inside a dispatch call, so the // provider had already answered — the attempt did reach it. - assert!(attempt_reached_upstream(&ProxyError::ContentFiltered( - "blocked by response guardrail".into() - ))); + assert!(attempt_reached_upstream(&ProxyError::ContentFiltered { + message: "blocked by response guardrail".into(), + unavailable: None, + })); // Gateway-side refusals never contacted anyone. assert!(!attempt_reached_upstream(&ProxyError::ModelNotFound( "nope".into() diff --git a/crates/aisix-proxy/src/audio.rs b/crates/aisix-proxy/src/audio.rs index 8c5a7dd6..90c47c45 100644 --- a/crates/aisix-proxy/src/audio.rs +++ b/crates/aisix-proxy/src/audio.rs @@ -933,7 +933,7 @@ async fn multipart_dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -943,8 +943,10 @@ async fn multipart_dispatch( reason = %reason, "guardrail blocked audio request (prompt field)", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } @@ -1432,7 +1434,7 @@ async fn multipart_dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -1444,10 +1446,11 @@ async fn multipart_dispatch( ); return Ok(AudioDispatchSuccess { usage_handled_by_stream: false, - response: ProxyError::ContentFiltered(crate::error::guardrail_block_message( + response: crate::error::guardrail_block_error( "response", guardrail_name.as_deref(), - )) + unavailable.as_deref(), + ) .into_response(), model_name, provider: provider_label, @@ -1617,7 +1620,7 @@ async fn speech_dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -1627,8 +1630,10 @@ async fn speech_dispatch( reason = %reason, "guardrail blocked /v1/audio/speech request", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } diff --git a/crates/aisix-proxy/src/chat.rs b/crates/aisix-proxy/src/chat.rs index a4c88271..b1187334 100644 --- a/crates/aisix-proxy/src/chat.rs +++ b/crates/aisix-proxy/src/chat.rs @@ -460,7 +460,7 @@ pub async fn chat_completions( // budget / rate-limit / bridge error after that point still // records which model the request targeted. ContentFiltered // (guardrail) sets `guardrail_blocked` for the Blocked tab. - let guardrail_blocked = matches!(err, ProxyError::ContentFiltered(_)); + let guardrail_blocked = matches!(err, ProxyError::ContentFiltered { .. }); let model_id_str = resolved_model_id.as_deref().unwrap_or(""); // AISIX-Cloud#1013: failed requests carry the (post-mask) // request body so a 4xx/5xx can be triaged from the log alone. @@ -1319,7 +1319,7 @@ async fn dispatch( GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } => { // The verdict's `reason` carries matched-pattern detail // (e.g. `"input blocked by literal \"forbidden-token\""`). @@ -1345,8 +1345,10 @@ async fn dispatch( reason = %reason, "guardrail blocked request" ); - return Err(with_model(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(with_model(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), ))); } GuardrailVerdict::Bypass { reason } => { @@ -2433,7 +2435,7 @@ async fn dispatch( GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } => { tracing::warn!( guardrail_hook = "output", @@ -2441,11 +2443,10 @@ async fn dispatch( reason = %reason, "guardrail blocked cached response", ); - return Err(with_model(ProxyError::ContentFiltered( - crate::error::guardrail_block_message( - "response", - guardrail_name.as_deref(), - ), + return Err(with_model(crate::error::guardrail_block_error( + "response", + guardrail_name.as_deref(), + unavailable.as_deref(), ))); } GuardrailVerdict::Bypass { reason } => { @@ -3020,7 +3021,7 @@ async fn dispatch( GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } => { // Output filter fires AFTER the upstream call, so the // provider has already billed for these tokens. Surface @@ -3068,10 +3069,11 @@ async fn dispatch( return Err(DispatchFailure::new( Some(model_id.clone()), Some(charge), - ProxyError::ContentFiltered(crate::error::guardrail_block_message( + crate::error::guardrail_block_error( "response", guardrail_name.as_deref(), - )), + unavailable.as_deref(), + ), ) .with_routing(routing)); } @@ -4066,7 +4068,7 @@ async fn dispatch_ensemble( GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } => { tracing::warn!( guardrail_hook = "output", @@ -4087,10 +4089,11 @@ async fn dispatch_ensemble( return Err(DispatchFailure::new( Some(model_id.to_string()), None, - ProxyError::ContentFiltered(crate::error::guardrail_block_message( + crate::error::guardrail_block_error( "response", guardrail_name.as_deref(), - )), + unavailable.as_deref(), + ), )); } GuardrailVerdict::Bypass { reason } => { @@ -5290,7 +5293,7 @@ where aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } => { tracing::warn!( guardrail_hook = "output", @@ -5307,7 +5310,7 @@ where &crate::error::guardrail_block_message( "response", guardrail_name.as_deref(), - ), + unavailable.as_deref()), ), ), ); @@ -5374,7 +5377,11 @@ where yield Ok::<_, Infallible>( Event::default().event("error").data(error_frame_payload( "content_filter", - "response blocked by content policy", + &crate::error::guardrail_block_message( + "response", + None, + Some(crate::error::TAG_OUTPUT_BUFFER_EXCEEDED), + ), )), ); break; @@ -5498,7 +5505,7 @@ where aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } => { tracing::warn!( guardrail_hook = "output", @@ -5514,7 +5521,7 @@ where &crate::error::guardrail_block_message( "response", guardrail_name.as_deref(), - ), + unavailable.as_deref()), )), ); true @@ -5586,7 +5593,7 @@ where aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } => { // Mirror the non-streaming path's #153 // redaction contract: the wire-level message @@ -5610,7 +5617,7 @@ where &crate::error::guardrail_block_message( "response", guardrail_name.as_deref(), - ), + unavailable.as_deref()), )), ); } diff --git a/crates/aisix-proxy/src/completions.rs b/crates/aisix-proxy/src/completions.rs index 61079177..0bcd938a 100644 --- a/crates/aisix-proxy/src/completions.rs +++ b/crates/aisix-proxy/src/completions.rs @@ -350,7 +350,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -360,8 +360,10 @@ async fn dispatch( reason = %reason, "guardrail blocked /v1/completions request", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } @@ -518,7 +520,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -535,11 +537,10 @@ async fn dispatch( // under-report spend the customer was charged for. Same // output analog as responses.rs #543 / chat.rs UpstreamCharge. return Ok(CompletionDispatchSuccess { - response: ProxyError::ContentFiltered( - crate::error::guardrail_block_message( - "response", - guardrail_name.as_deref(), - ), + response: crate::error::guardrail_block_error( + "response", + guardrail_name.as_deref(), + unavailable.as_deref(), ) .into_response(), provider: provider_label, diff --git a/crates/aisix-proxy/src/embeddings.rs b/crates/aisix-proxy/src/embeddings.rs index ba228440..5996916d 100644 --- a/crates/aisix-proxy/src/embeddings.rs +++ b/crates/aisix-proxy/src/embeddings.rs @@ -362,7 +362,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 keep the matched-pattern detail in ops logs only; the @@ -373,8 +373,10 @@ async fn dispatch( reason = %reason, "guardrail blocked /v1/embeddings request", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } diff --git a/crates/aisix-proxy/src/error.rs b/crates/aisix-proxy/src/error.rs index 74d6c6fa..a9e32a0c 100644 --- a/crates/aisix-proxy/src/error.rs +++ b/crates/aisix-proxy/src/error.rs @@ -290,8 +290,26 @@ pub enum ProxyError { /// [`guardrail_block_message`] — generic policy wording plus the NAME /// of the guardrail that fired (#519 B.4b) — and emits the rich /// detail to `tracing` for operators. - #[error("{0}")] - ContentFiltered(String), + /// + /// `unavailable` carries the guardrail's bounded failure tag when the + /// refusal was an AVAILABILITY failure on a `fail_open: false` row — + /// the screening service was unreachable, or an operator-supplied + /// script produced no usable verdict — rather than a content decision. + /// It is the same closed, label-safe vocabulary + /// `GuardrailVerdict::Block::unavailable` carries, never free text and + /// never matched content, so it is safe on the wire. + /// + /// Keeping the two inside ONE variant is deliberate: `status()`, + /// `kind()`, and the dispatch-loop predicates must not diverge between + /// them (a guardrail refusal is a guardrail refusal for retry, + /// failover and outcome-classification purposes), so the distinction + /// lives only where it is read by a human or branched on by an SDK — + /// the message and `error.code`. + #[error("{message}")] + ContentFiltered { + message: String, + unavailable: Option, + }, // Carries cp-api's structured reason. Display forwards the cp-api // message verbatim (it's already a complete customer sentence — // " budget '' exceeded ($X/period). Resets …"); the @@ -330,6 +348,23 @@ pub enum ProxyError { Bridge(#[from] BridgeError), } +/// Failure tags for the two refusals the PROXY itself raises on a +/// guardrail's behalf, where no `GuardrailVerdict` exists to carry one. +/// Same closed, label-safe vocabulary as a kind's own tag, and here for +/// the same reason: in both cases the content was never screened, so +/// telling the caller its content violated a policy states something that +/// did not happen. +/// +/// `output_buffer_exceeded` — a streamed response outgrew the hold-back +/// cap and the row is fail-closed, so it is refused unscanned. +/// `unscannable_body` — the body could not be walked, so the guardrail was +/// never offered the content to scan. +/// `mask_writeback_failed` — a mask verdict could not be spliced back into +/// the body, so the request is refused rather than forwarded unmasked. +pub(crate) const TAG_OUTPUT_BUFFER_EXCEEDED: &str = "output_buffer_exceeded"; +pub(crate) const TAG_UNSCANNABLE_BODY: &str = "unscannable_body"; +pub(crate) const TAG_MASK_WRITEBACK_FAILED: &str = "mask_writeback_failed"; + /// The caller-visible message for a guardrail `Block` verdict. /// /// Carries WHICH guardrail fired — `guardrail_name` is operator-assigned @@ -341,10 +376,46 @@ pub enum ProxyError { /// family builds its rejection text through this helper so the wording can't /// drift between siblings, even where the envelope differs (422 or an SSE /// error event on the LLM routes; an `isError` tool result on `/mcp`). -pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>) -> String { - match guardrail_name { - Some(name) => format!("{side} blocked by content policy (guardrail '{name}')"), - None => format!("{side} blocked by content policy"), +/// +/// `unavailable` splits the sentence in two. A guardrail that REFUSED the +/// content and a guardrail that COULD NOT EVALUATE it are the same 422 to +/// every consumer downstream, and until this parameter existed they were +/// also the same sentence — so an operator whose fail-closed row was +/// broken (a screening service down, a script returning a verdict the +/// gateway does not understand) saw their own working policy's message on +/// every single request, with nothing in the response to suggest +/// otherwise. AISIX-Cloud#1365 split the two on the audit event and the +/// metric; this is the third surface, the one the caller actually reads. +/// The tag is named in the text because it is what an operator greps for +/// and what the dashboard shows. +pub(crate) fn guardrail_block_message( + side: &str, + guardrail_name: Option<&str>, + unavailable: Option<&str>, +) -> String { + match (unavailable, guardrail_name) { + (Some(tag), Some(name)) => { + format!("{side} rejected: guardrail '{name}' could not evaluate it ({tag})") + } + (Some(tag), None) => format!("{side} rejected: a guardrail could not evaluate it ({tag})"), + (None, Some(name)) => format!("{side} blocked by content policy (guardrail '{name}')"), + (None, None) => format!("{side} blocked by content policy"), + } +} + +/// [`guardrail_block_message`] wrapped in the error the LLM handlers +/// return. The `Option` sites (SSE error frames, `/mcp` tool +/// results, hand-built JSON bodies) use the message helper directly; every +/// site that returns a [`ProxyError`] goes through this one so the message +/// and the `unavailable` tag can never be set from different verdicts. +pub(crate) fn guardrail_block_error( + side: &str, + guardrail_name: Option<&str>, + unavailable: Option<&str>, +) -> ProxyError { + ProxyError::ContentFiltered { + message: guardrail_block_message(side, guardrail_name, unavailable), + unavailable: unavailable.map(str::to_owned), } } @@ -373,7 +444,7 @@ impl ProxyError { ProxyError::WebSocketUpgradeRequired { status, .. } => *status, ProxyError::ProviderUnavailable => StatusCode::SERVICE_UNAVAILABLE, ProxyError::AllCandidatesUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, - ProxyError::ContentFiltered(_) => StatusCode::UNPROCESSABLE_ENTITY, + ProxyError::ContentFiltered { .. } => StatusCode::UNPROCESSABLE_ENTITY, ProxyError::BudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS, ProxyError::RequestTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, ProxyError::RateLimit(_) => StatusCode::TOO_MANY_REQUESTS, @@ -411,7 +482,7 @@ impl ProxyError { ProxyError::RequestTooLarge { .. } => "invalid_request_error", ProxyError::ProviderUnavailable => "provider_unavailable", ProxyError::AllCandidatesUnavailable { .. } => "all_candidates_unavailable", - ProxyError::ContentFiltered(_) => "content_filter", + ProxyError::ContentFiltered { .. } => "content_filter", ProxyError::BudgetExceeded(_) => "billing_error", ProxyError::RateLimit(_) => "rate_limit_exceeded", ProxyError::PolicyRateLimit { .. } => "rate_limit_exceeded", @@ -533,6 +604,18 @@ impl ProxyError { } ProxyError::JwtIdentityUnmapped => env.with_code("jwt_identity_unmapped"), ProxyError::JwksUnavailable => env.with_code("jwks_unavailable"), + // Same stable-code convention, for the one distinction a + // caller cannot make from `error.type`: `content_filter` + // covers both a policy refusal and a fail-closed row that + // could not evaluate the request. The `type` stays + // `content_filter` — it is a shipped contract and both really + // are guardrail refusals — while the code lets an SDK branch, + // and lets an operator's dashboard stop counting a broken + // script as policy volume. + ProxyError::ContentFiltered { + unavailable: Some(_), + .. + } => env.with_code("guardrail_unavailable"), _ => env, } } @@ -1115,6 +1198,56 @@ mod tests { json } + #[test] + fn a_guardrail_that_could_not_evaluate_does_not_claim_a_content_decision() { + // The whole defect in one assertion: the two refusals shared a + // sentence, so a broken fail-closed row looked exactly like a + // working policy to the caller. + let policy = guardrail_block_error("request", Some("my-guard"), None); + let broken = guardrail_block_error("request", Some("my-guard"), Some("custom_no_verdict")); + assert_eq!( + policy.to_string(), + "request blocked by content policy (guardrail 'my-guard')" + ); + assert_ne!(policy.to_string(), broken.to_string()); + assert!(broken.to_string().contains("could not evaluate")); + assert!(!broken.to_string().contains("content policy")); + // The tag is named so an operator can grep the message straight + // onto the dashboard series that carries the same value. + assert!(broken.to_string().contains("custom_no_verdict")); + // Both still name the firing row (#519 B.4b) and neither carries + // matched content (#153). + assert!(broken.to_string().contains("my-guard")); + } + + #[test] + fn an_unattributed_availability_refusal_still_says_which_kind_of_refusal_it_is() { + let msg = guardrail_block_message("response", None, Some("output_buffer_exceeded")); + assert_eq!( + msg, + "response rejected: a guardrail could not evaluate it (output_buffer_exceeded)" + ); + } + + #[test] + fn the_availability_refusal_keeps_the_status_and_type_but_adds_a_machine_readable_code() { + let policy = guardrail_block_error("request", Some("g"), None); + let broken = guardrail_block_error("request", Some("g"), Some("custom_unknown_action")); + // Same shipped contract: an SDK branching on status or type sees + // no change, and an operator alert on `content_filter` keeps + // counting both. + assert_eq!(policy.status(), broken.status()); + assert_eq!(policy.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(policy.kind(), "content_filter"); + assert_eq!(broken.kind(), "content_filter"); + // The code is the new discriminator. + assert_eq!(policy.envelope().error.code, None); + assert_eq!( + broken.envelope().error.code.as_deref(), + Some("guardrail_unavailable") + ); + } + #[tokio::test] async fn anthropic_envelope_404_maps_to_not_found_error() { let err = ProxyError::ModelNotFound("claude-x".into()); @@ -1171,7 +1304,7 @@ mod tests { // Content-filter rejections share 422 with the OpenAI side; // Anthropic-canonical 422 maps to `invalid_request_error` // (no dedicated content-filter type in the SDK literal). - let err = ProxyError::ContentFiltered("request blocked by content policy".into()); + let err = guardrail_block_error("request", None, None); let resp = err.into_anthropic_response(); assert_anthropic_envelope( resp, diff --git a/crates/aisix-proxy/src/images.rs b/crates/aisix-proxy/src/images.rs index fe7df069..fcc5de00 100644 --- a/crates/aisix-proxy/src/images.rs +++ b/crates/aisix-proxy/src/images.rs @@ -302,7 +302,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -312,8 +312,10 @@ async fn dispatch( reason = %reason, "guardrail blocked /v1/images/generations request", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } diff --git a/crates/aisix-proxy/src/images_edits.rs b/crates/aisix-proxy/src/images_edits.rs index 9779aee0..88ae59f9 100644 --- a/crates/aisix-proxy/src/images_edits.rs +++ b/crates/aisix-proxy/src/images_edits.rs @@ -342,7 +342,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -352,8 +352,10 @@ async fn dispatch( reason = %reason, "guardrail blocked /v1/images/edits request (prompt field)", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index ffe5b234..ce6cd049 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -472,7 +472,7 @@ async fn scan_input_blob( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -481,8 +481,10 @@ async fn scan_input_blob( reason = %reason, "guardrail blocked jobs request", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } Ok(()) @@ -525,7 +527,7 @@ async fn scan_output_blob( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -534,8 +536,10 @@ async fn scan_output_blob( reason = %reason, "guardrail blocked jobs response", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("response", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "response", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } Ok(()) diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index e9a036a3..9b7754a8 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -418,7 +418,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -444,7 +444,12 @@ async fn dispatch( trace, /* dispatched */ false, ); - return jsonrpc_guardrail_block(rpc_id, "tool call", guardrail_name.as_deref()); + return jsonrpc_guardrail_block( + rpc_id, + "tool call", + guardrail_name.as_deref(), + unavailable.as_deref(), + ); } } @@ -510,7 +515,12 @@ async fn dispatch( trace, /* dispatched */ false, ); - return jsonrpc_guardrail_block(rpc_id, "tool call", None); + return jsonrpc_guardrail_block( + rpc_id, + "tool call", + None, + Some(crate::error::TAG_MASK_WRITEBACK_FAILED), + ); } } } @@ -535,7 +545,10 @@ async fn dispatch( ); axum::body::Bytes::from(rewritten) } - SegmentPassOutcome::Block(guardrail_name) => { + SegmentPassOutcome::Block { + guardrail_name, + unavailable, + } => { emit_tool_call_usage( state, &snapshot, @@ -553,7 +566,12 @@ async fn dispatch( trace, /* dispatched */ false, ); - return jsonrpc_guardrail_block(rpc_id, "tool call", guardrail_name.as_deref()); + return jsonrpc_guardrail_block( + rpc_id, + "tool call", + guardrail_name.as_deref(), + unavailable.as_deref(), + ); } } } @@ -629,7 +647,10 @@ async fn dispatch( }; if let Some(chain) = &guardrail_chain { match apply_output_guardrails(chain, &resp_bytes, &mcp_tool, &mut monitor_hits).await { - ToolResultOutcome::Block(guardrail_name) => { + ToolResultOutcome::Block { + guardrail_name, + unavailable, + } => { emit_tool_call_usage( state, &snapshot, @@ -651,6 +672,7 @@ async fn dispatch( rpc_id, "tool result", guardrail_name.as_deref(), + unavailable.as_deref(), ); } ToolResultOutcome::Allow(Some((rewritten, counts))) => { @@ -750,9 +772,15 @@ enum SegmentPassOutcome { Keep, /// Masked replacements were spliced in. Rewritten(Vec), - /// A segment-moderating member blocked; the value is the firing - /// guardrail's name (`None` for a fail-closed walk failure). - Block(Option), + /// A segment-moderating member blocked. Carries the firing + /// guardrail's name and, when the refusal was an availability failure + /// rather than a content decision, its bounded failure tag — the same + /// two the `Block` verdict carries, so the `/mcp` tool result says + /// which of the two happened just like every other family does. + Block { + guardrail_name: Option, + unavailable: Option, + }, } /// Segment pass over the request's `params.arguments` string leaves. @@ -796,7 +824,10 @@ async fn moderate_selected_segments( // Structurally impossible (every caller's body already parsed as // JSON) — fail closed rather than let content bypass the pass. tracing::warn!(error = %err, "mcp segment collect walk failed; blocking"); - return SegmentPassOutcome::Block(None); + return SegmentPassOutcome::Block { + guardrail_name: None, + unavailable: Some(crate::error::TAG_UNSCANNABLE_BODY.to_owned()), + }; } if texts.is_empty() { return SegmentPassOutcome::Keep; @@ -810,7 +841,7 @@ async fn moderate_selected_segments( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = outcome.verdict { tracing::warn!( @@ -818,7 +849,10 @@ async fn moderate_selected_segments( reason = %reason, "guardrail blocked MCP content in the segment pass" ); - return SegmentPassOutcome::Block(guardrail_name); + return SegmentPassOutcome::Block { + guardrail_name, + unavailable, + }; } let Some(masked) = outcome.masked else { return SegmentPassOutcome::Keep; @@ -833,7 +867,10 @@ async fn moderate_selected_segments( masked = masked.len(), "mcp segment mask drifted from the collect walk; blocking" ); - return SegmentPassOutcome::Block(None); + return SegmentPassOutcome::Block { + guardrail_name: None, + unavailable: Some(crate::error::TAG_MASK_WRITEBACK_FAILED.to_owned()), + }; } let mut cursor = 0usize; match crate::json_splice::rewrite_string_values(body, pred, |t| { @@ -851,7 +888,10 @@ async fn moderate_selected_segments( } Err(err) => { tracing::warn!(error = %err, "mcp segment mask splice failed; blocking"); - SegmentPassOutcome::Block(None) + SegmentPassOutcome::Block { + guardrail_name: None, + unavailable: Some(crate::error::TAG_MASK_WRITEBACK_FAILED.to_owned()), + } } } } @@ -881,10 +921,14 @@ fn tool_call_capture( /// Outcome of the output-hook guardrail pass over an MCP tool result. enum ToolResultOutcome { - /// Reject the tool result. The inner value is the firing guardrail's - /// name, or `None` for a fail-closed block (unparseable body / splice - /// failure). - Block(Option), + /// Reject the tool result. Carries the firing guardrail's name (or + /// `None` for a fail-closed block on an unparseable body / splice + /// failure) and, when the refusal was an availability failure rather + /// than a content decision, its bounded failure tag. + Block { + guardrail_name: Option, + unavailable: Option, + }, /// Release the tool result; `Some` carries the mask-rewritten body /// bytes and the per-detector counts. Allow(Option<(Vec, crate::redact::RedactionCounts)>), @@ -910,7 +954,12 @@ async fn apply_output_guardrails( // guardrail — block rather than allow. let value: serde_json::Value = match serde_json::from_slice(response_bytes) { Ok(value) => value, - Err(_) => return ToolResultOutcome::Block(None), + Err(_) => { + return ToolResultOutcome::Block { + guardrail_name: None, + unavailable: Some(crate::error::TAG_UNSCANNABLE_BODY.to_owned()), + } + } }; // A protocol-level error envelope (no `result`) has no tool output to scan. let Some(result) = value.get("result") else { @@ -984,7 +1033,7 @@ async fn apply_output_guardrails( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -993,7 +1042,10 @@ async fn apply_output_guardrails( reason = %reason, "guardrail blocked MCP tool result" ); - return ToolResultOutcome::Block(guardrail_name); + return ToolResultOutcome::Block { + guardrail_name, + unavailable, + }; } // Mask write-back over the same surface the scan covers // (`tool_result_path`; `name`/`uri` stay untouched — they address a @@ -1023,7 +1075,10 @@ async fn apply_output_guardrails( error = %err, "mcp output mask splice failed; blocking tool result", ); - return ToolResultOutcome::Block(None); + return ToolResultOutcome::Block { + guardrail_name: None, + unavailable: Some(crate::error::TAG_MASK_WRITEBACK_FAILED.to_owned()), + }; } } } @@ -1041,8 +1096,14 @@ async fn apply_output_guardrails( { SegmentPassOutcome::Keep => {} SegmentPassOutcome::Rewritten(bytes) => current = Some(bytes), - SegmentPassOutcome::Block(guardrail_name) => { - return ToolResultOutcome::Block(guardrail_name) + SegmentPassOutcome::Block { + guardrail_name, + unavailable, + } => { + return ToolResultOutcome::Block { + guardrail_name, + unavailable, + } } } } @@ -1218,8 +1279,9 @@ fn jsonrpc_guardrail_block( id: Option, side: &str, guardrail_name: Option<&str>, + unavailable: Option<&str>, ) -> Response { - let message = crate::error::guardrail_block_message(side, guardrail_name); + let message = crate::error::guardrail_block_message(side, guardrail_name, unavailable); let body = serde_json::json!({ "jsonrpc": "2.0", "id": id.unwrap_or(serde_json::Value::Null), @@ -2217,7 +2279,7 @@ mod tests { monitor_hits: &mut Vec, ) -> Option> { match apply_output_guardrails(chain, response_bytes, tool, monitor_hits).await { - ToolResultOutcome::Block(name) => Some(name), + ToolResultOutcome::Block { guardrail_name, .. } => Some(guardrail_name), ToolResultOutcome::Allow(_) => None, } } @@ -2266,7 +2328,7 @@ mod tests { other => panic!( "expected a rewritten Allow, got {}", match other { - ToolResultOutcome::Block(_) => "Block", + ToolResultOutcome::Block { .. } => "Block", ToolResultOutcome::Allow(None) => "Allow(None)", ToolResultOutcome::Allow(_) => unreachable!(), } @@ -2384,7 +2446,10 @@ mod tests { let body = br#"{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"forbidden-token"}]}}"#; assert!(matches!( apply_output_guardrails(&chain, body, "report", &mut Vec::new()).await, - ToolResultOutcome::Block(Some(_)), + ToolResultOutcome::Block { + guardrail_name: Some(_), + .. + }, )); let hits = chain.enforced_hits(); @@ -2681,6 +2746,7 @@ mod tests { Some(serde_json::json!(42)), "tool result", Some("mcp-output-guard"), + None, ); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( diff --git a/crates/aisix-proxy/src/messages.rs b/crates/aisix-proxy/src/messages.rs index 0a92b65f..6f9608ae 100644 --- a/crates/aisix-proxy/src/messages.rs +++ b/crates/aisix-proxy/src/messages.rs @@ -624,7 +624,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // AISIX-Cloud#1013: mask before returning so the failure @@ -639,13 +639,12 @@ async fn dispatch( reason = %reason, "guardrail blocked /v1/messages request", ); - return Err( - ProxyError::ContentFiltered(crate::error::guardrail_block_message( - "request", - guardrail_name.as_deref(), - )) - .into(), - ); + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), + ) + .into()); } } // #932: mask-action PII rules rewrite the Anthropic-native body in @@ -1623,7 +1622,7 @@ async fn anthropic_passthrough_dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -1632,11 +1631,10 @@ async fn anthropic_passthrough_dispatch( reason = %reason, "guardrail blocked /v1/messages passthrough response", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message( - "response", - guardrail_name.as_deref(), - ), + return Err(crate::error::guardrail_block_error( + "response", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } @@ -2225,7 +2223,7 @@ async fn cross_provider_dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -2234,8 +2232,10 @@ async fn cross_provider_dispatch( reason = %reason, "guardrail blocked /v1/messages response", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("response", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "response", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } @@ -2461,7 +2461,7 @@ fn build_anthropic_sse_stream( max_buffer_bytes = max_hold, "streaming /v1/messages response exceeded hold-back cap; failing closed", ); - yield Ok(bytes::Bytes::from(guardrail_block_frame(None))); + yield Ok(bytes::Bytes::from(guardrail_block_frame(None, Some(crate::error::TAG_OUTPUT_BUFFER_EXCEEDED)))); return; } held_chunks.push(chunk); @@ -2558,7 +2558,7 @@ fn build_anthropic_sse_stream( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -2569,7 +2569,7 @@ fn build_anthropic_sse_stream( ); // Hold-back: the held chunks are dropped — the matched // content never reached the wire. - let frame = guardrail_block_frame(guardrail_name.as_deref()); + let frame = guardrail_block_frame(guardrail_name.as_deref(), unavailable.as_deref()); yield Ok(bytes::Bytes::from(frame)); return; } @@ -2624,14 +2624,14 @@ fn build_anthropic_sse_stream( /// with serde_json so an operator-supplied guardrail name is JSON-escaped /// correctly; the message carries the firing guardrail's name (#519 B.4b) /// but never the matched-pattern detail (#153). -fn guardrail_block_frame(guardrail_name: Option<&str>) -> String { +fn guardrail_block_frame(guardrail_name: Option<&str>, unavailable: Option<&str>) -> String { format!( "event: error\ndata: {}\n\n", serde_json::json!({ "type": "error", "error": { "type": "content_filter", - "message": crate::error::guardrail_block_message("response", guardrail_name), + "message": crate::error::guardrail_block_message("response", guardrail_name, unavailable), } }) ) @@ -3489,7 +3489,7 @@ where max_buffer_bytes = max_hold, "streaming /v1/messages passthrough exceeded hold-back cap; failing closed", ); - yield Ok(Bytes::from(guardrail_block_frame(None))); + yield Ok(Bytes::from(guardrail_block_frame(None, Some(crate::error::TAG_OUTPUT_BUFFER_EXCEEDED)))); return; } held.extend_from_slice(bytes); @@ -3591,7 +3591,7 @@ where if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -3601,7 +3601,7 @@ where "guardrail blocked streaming /v1/messages passthrough response", ); blocked = true; - let frame = guardrail_block_frame(guardrail_name.as_deref()); + let frame = guardrail_block_frame(guardrail_name.as_deref(), unavailable.as_deref()); yield Ok(Bytes::from(frame)); } } diff --git a/crates/aisix-proxy/src/passthrough_route.rs b/crates/aisix-proxy/src/passthrough_route.rs index 40acb595..6b29e9cd 100644 --- a/crates/aisix-proxy/src/passthrough_route.rs +++ b/crates/aisix-proxy/src/passthrough_route.rs @@ -591,7 +591,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -602,10 +602,11 @@ async fn dispatch( "guardrail blocked passthrough-route request", ); return Err(RouteError::of( - ProxyError::ContentFiltered(crate::error::guardrail_block_message( + crate::error::guardrail_block_error( "request", guardrail_name.as_deref(), - )), + unavailable.as_deref(), + ), &auth, )); } @@ -871,7 +872,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -885,10 +886,11 @@ async fn dispatch( // let the shared error path report the 422. telemetry.emitted = true; return Err(RouteError::of( - ProxyError::ContentFiltered(crate::error::guardrail_block_message( + crate::error::guardrail_block_error( "response", guardrail_name.as_deref(), - )), + unavailable.as_deref(), + ), &auth, )); } @@ -1641,11 +1643,11 @@ fn frame_delta(protocol: PassthroughProtocol, frame: &[u8]) -> (String, Option

) -> Bytes { +fn guardrail_error_frame(guardrail_name: Option<&str>, unavailable: Option<&str>) -> Bytes { let payload = serde_json::json!({ "error": { "type": "content_filter", - "message": crate::error::guardrail_block_message("response", guardrail_name), + "message": crate::error::guardrail_block_message("response", guardrail_name, unavailable), } }); Bytes::from(format!("event: error\ndata: {payload}\n\n")) @@ -1757,7 +1759,7 @@ fn stream_response( GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } => { tracing::warn!( guardrail_hook = "output", @@ -1766,7 +1768,7 @@ fn stream_response( "guardrail blocked passthrough-route stream (window)", ); blocked = true; - yield Ok(guardrail_error_frame(guardrail_name.as_deref())); + yield Ok(guardrail_error_frame(guardrail_name.as_deref(), unavailable.as_deref())); break 'outer; } _ => { @@ -1800,7 +1802,7 @@ fn stream_response( "passthrough-route stream exceeded the guardrail buffer cap (fail-closed)", ); blocked = true; - yield Ok(guardrail_error_frame(None)); + yield Ok(guardrail_error_frame(None, Some(crate::error::TAG_OUTPUT_BUFFER_EXCEEDED))); break 'outer; } } @@ -1835,7 +1837,7 @@ fn stream_response( if let GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = scan_output(&chain, &route_name, &text, &mut telemetry).await { @@ -1849,7 +1851,7 @@ fn stream_response( // forwarded under EndOfStreamCheck cannot be unsent — // the error frame is the caller-visible signal either way. pending.clear(); - yield Ok(guardrail_error_frame(guardrail_name.as_deref())); + yield Ok(guardrail_error_frame(guardrail_name.as_deref(), unavailable.as_deref())); telemetry.guardrail_blocked = true; telemetry.stream_reached_end = true; telemetry.emit(); diff --git a/crates/aisix-proxy/src/realtime.rs b/crates/aisix-proxy/src/realtime.rs index b888fe4e..fc739263 100644 --- a/crates/aisix-proxy/src/realtime.rs +++ b/crates/aisix-proxy/src/realtime.rs @@ -678,9 +678,10 @@ async fn run_session( }))) .await; close_status = 400; - session_error = Some(ProxyError::ContentFiltered( - "realtime frame blocked by a guardrail".into(), - )); + session_error = Some(ProxyError::ContentFiltered { + message: "realtime frame blocked by a guardrail".into(), + unavailable: None, + }); break; } } @@ -724,9 +725,10 @@ async fn run_session( }))) .await; close_status = 400; - session_error = Some(ProxyError::ContentFiltered( - "realtime frame blocked by a guardrail".into(), - )); + session_error = Some(ProxyError::ContentFiltered { + message: "realtime frame blocked by a guardrail".into(), + unavailable: None, + }); break; } } @@ -916,7 +918,7 @@ async fn guardrail_block_event( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { let side = if input_side { "input" } else { "output" }; @@ -928,6 +930,7 @@ async fn guardrail_block_event( let msg = crate::error::guardrail_block_message( if input_side { "request" } else { "response" }, guardrail_name.as_deref(), + unavailable.as_deref(), ); return Some( serde_json::json!({ diff --git a/crates/aisix-proxy/src/rerank.rs b/crates/aisix-proxy/src/rerank.rs index 3f2e7878..0d07aa62 100644 --- a/crates/aisix-proxy/src/rerank.rs +++ b/crates/aisix-proxy/src/rerank.rs @@ -313,7 +313,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -323,8 +323,10 @@ async fn dispatch( reason = %reason, "guardrail blocked /v1/rerank request", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } diff --git a/crates/aisix-proxy/src/responses.rs b/crates/aisix-proxy/src/responses.rs index a41eb0e5..b498e834 100644 --- a/crates/aisix-proxy/src/responses.rs +++ b/crates/aisix-proxy/src/responses.rs @@ -590,7 +590,7 @@ async fn dispatch( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only; the @@ -609,13 +609,12 @@ async fn dispatch( reason = %reason, "guardrail blocked /v1/responses request", ); - return Err( - ProxyError::ContentFiltered(crate::error::guardrail_block_message( - "request", - guardrail_name.as_deref(), - )) - .into(), - ); + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), + ) + .into()); } // #932: mask-action PII rules rewrite the Responses body in place // AFTER the block check passes — both the verbatim passthrough and @@ -1316,8 +1315,10 @@ async fn responses_to_target( max_buffer_bytes, "streaming /v1/responses output exceeded buffer cap; failing closed", ); - return Err(ProxyError::ContentFiltered( - "response blocked by content policy".into(), + return Err(crate::error::guardrail_block_error( + "response", + None, + Some(crate::error::TAG_OUTPUT_BUFFER_EXCEEDED), )); } buf.extend_from_slice(&chunk); @@ -1349,7 +1350,7 @@ async fn responses_to_target( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -1359,8 +1360,10 @@ async fn responses_to_target( reason = %reason, "guardrail blocked streaming /v1/responses response", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("response", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "response", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } // #932: the whole SSE response is held here — mask the frames @@ -1748,7 +1751,7 @@ async fn responses_to_target( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Per #153 the matched-pattern detail stays in ops logs only. @@ -1764,10 +1767,11 @@ async fn responses_to_target( // customer's ledger underreport spend they were charged for. // This is the output analog of chat.rs's UpstreamCharge. return Ok(ResponseDispatchSuccess { - response: ProxyError::ContentFiltered(crate::error::guardrail_block_message( + response: crate::error::guardrail_block_error( "response", guardrail_name.as_deref(), - )) + unavailable.as_deref(), + ) .into_response(), provider: provider_label, usage, @@ -2259,7 +2263,7 @@ async fn responses_cross_provider_to_target( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( @@ -2272,10 +2276,11 @@ async fn responses_cross_provider_to_target( // carry the billed usage (marked guardrail_blocked) so the // ledger doesn't underreport spend. return Ok(ResponseDispatchSuccess { - response: ProxyError::ContentFiltered(crate::error::guardrail_block_message( + response: crate::error::guardrail_block_error( "response", guardrail_name.as_deref(), - )) + unavailable.as_deref(), + ) .into_response(), provider: provider_label, usage: Some(usage), diff --git a/crates/aisix-proxy/src/responses_bridge.rs b/crates/aisix-proxy/src/responses_bridge.rs index 09cbbe1d..206cb743 100644 --- a/crates/aisix-proxy/src/responses_bridge.rs +++ b/crates/aisix-proxy/src/responses_bridge.rs @@ -1175,7 +1175,7 @@ pub fn build_responses_bridge_stream( "streaming /v1/responses (cross-provider) output exceeded buffer cap; failing closed", ); guard.comp().guardrail_blocked = true; - yield Ok(bytes::Bytes::from(guardrail_error_frame(None))); + yield Ok(bytes::Bytes::from(guardrail_error_frame(None, Some(crate::error::TAG_OUTPUT_BUFFER_EXCEEDED)))); return; } @@ -1288,7 +1288,7 @@ pub fn build_responses_bridge_stream( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { tracing::warn!( guardrail_hook = "output", @@ -1297,7 +1297,7 @@ pub fn build_responses_bridge_stream( "guardrail blocked streaming /v1/responses (cross-provider) response", ); guard.comp().guardrail_blocked = true; - yield Ok(bytes::Bytes::from(guardrail_error_frame(guardrail_name.as_deref()))); + yield Ok(bytes::Bytes::from(guardrail_error_frame(guardrail_name.as_deref(), unavailable.as_deref()))); return; } if seg_rewrote { @@ -1347,13 +1347,13 @@ pub fn build_responses_bridge_stream( /// Responses-API SSE `error` frame for an output-guardrail block. Carries the /// firing guardrail's name (#519 B.4b) but never the matched-pattern detail. -fn guardrail_error_frame(guardrail_name: Option<&str>) -> String { +fn guardrail_error_frame(guardrail_name: Option<&str>, unavailable: Option<&str>) -> String { format!( "event: error\ndata: {}\n\n", json!({ "type": "error", "code": "content_filter", - "message": crate::error::guardrail_block_message("response", guardrail_name), + "message": crate::error::guardrail_block_message("response", guardrail_name, unavailable), }) ) } diff --git a/crates/aisix-proxy/src/videos.rs b/crates/aisix-proxy/src/videos.rs index 2959e295..a4787c17 100644 --- a/crates/aisix-proxy/src/videos.rs +++ b/crates/aisix-proxy/src/videos.rs @@ -1680,7 +1680,7 @@ async fn dispatch_create( if let aisix_guardrails::GuardrailVerdict::Block { reason, guardrail_name, - .. + unavailable, } = verdict { // Matched-pattern detail stays in ops logs only (#153). @@ -1690,8 +1690,10 @@ async fn dispatch_create( reason = %reason, "guardrail blocked /v1/videos request", ); - return Err(ProxyError::ContentFiltered( - crate::error::guardrail_block_message("request", guardrail_name.as_deref()), + return Err(crate::error::guardrail_block_error( + "request", + guardrail_name.as_deref(), + unavailable.as_deref(), )); } } diff --git a/tests/e2e/src/cases/guardrail-custom-script-e2e.test.ts b/tests/e2e/src/cases/guardrail-custom-script-e2e.test.ts index 1c3b2b04..46e1984d 100644 --- a/tests/e2e/src/cases/guardrail-custom-script-e2e.test.ts +++ b/tests/e2e/src/cases/guardrail-custom-script-e2e.test.ts @@ -270,7 +270,17 @@ describe("custom script guardrail e2e: operator script screens against its own s // a block: the row is a security control, so its failure must not be // an open door. const before = upstream!.receivedRequests.length; - await expectBlocked(`please help with ${OUTAGE_MARKER}`); + const caught = await expectBlocked(`please help with ${OUTAGE_MARKER}`); expect(upstream!.receivedRequests.length).toBe(before); + + // ...and it must not look like the block above. Same status and the + // same `content_filter` type — both really are guardrail refusals — + // but the outage says so, in the message and in a machine-readable + // code, so an operator is not left debugging a policy that is fine. + const err = caught.error as { message?: string; code?: string }; + expect(err.message).not.toContain("blocked by content policy"); + expect(err.message).toContain("could not evaluate"); + expect(err.message).toContain("custom_script_error"); + expect(err.code).toBe("guardrail_unavailable"); }); }); diff --git a/tests/e2e/src/cases/guardrail-custom-verdict-diagnostics-e2e.test.ts b/tests/e2e/src/cases/guardrail-custom-verdict-diagnostics-e2e.test.ts new file mode 100644 index 00000000..b7dd7374 --- /dev/null +++ b/tests/e2e/src/cases/guardrail-custom-verdict-diagnostics-e2e.test.ts @@ -0,0 +1,298 @@ +import { createHash, randomUUID } from "node:crypto"; +import OpenAI, { APIError } from "openai"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + SeedClient, + spawnApp, + startOpenAiUpstream, + waitConfigPropagation, + type OpenAiUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: a kind=custom script that MISBEHAVES must not be mistakable for a +// content policy that is WORKING. +// +// The reported defect: a script returning `{action: "allow"}` — the obvious +// word for "let it through", but not one of the accepted actions — fell into +// the unknown-action arm, which is a script FAILURE, and the release's +// fail-closed default turned every failure into a block. The caller got +// `422 request blocked by content policy (guardrail '')`: byte for byte +// the response a correctly-firing policy produces. So the operator's whole +// traffic was refused, and nothing the caller, the logs, or a dashboard +// showed said "your script is broken" rather than "your policy is busy". +// +// Fixed on four surfaces, all asserted here against a real aisix binary +// running real scripts: +// 1. `allow` is an accepted synonym of `none` — the reported script works. +// 2. A script fault still refuses (fail-closed is right: a hook that did +// not produce a verdict has not screened anything), but the caller's +// message says the guardrail could not evaluate the request, and the +// envelope carries `error.code = "guardrail_unavailable"` — while a +// genuine content block keeps its old message and carries no code. +// 3. The gateway logs name the offending action AND the accepted +// vocabulary, so the fix is readable off one log line. +// 4. The latency histogram separates the failure modes by `error_type` +// (`custom_unknown_action` / `custom_no_verdict`), so a dashboard +// stops counting a broken script as policy volume. + +const CALLER = "sk-custom-verdict-diag-caller"; +const hash = (s: string) => createHash("sha256").update(s).digest("hex"); + +const BLOCK_MARKER = "customverdictblockmarker"; + +/** model name → the script its guardrail runs. */ +const CASES = { + "cvd-allow": ` +export function checkInput() { + return { action: "allow" }; +}`, + "cvd-none": ` +export function checkInput(ctx) { + if (ctx.text.includes("${BLOCK_MARKER}")) { + return { action: "block", reason_code: "R-1" }; + } + return { action: "none" }; +}`, + "cvd-unknown": ` +export function checkInput() { + return { action: "permit" }; +}`, + "cvd-noreturn": ` +export function checkInput() { + // falls off the end — decides nothing +}`, + "cvd-empty": ` +export function checkInput() { + return {}; +}`, +} as const; + +type CaseModel = keyof typeof CASES; + +function guardrailCount(scrape: string, labels: Record): number { + let sum = 0; + for (const line of scrape.split("\n")) { + if (!line.startsWith("aisix_guardrail_latency_seconds_count{")) continue; + if (!Object.entries(labels).every(([k, v]) => line.includes(`${k}="${v}"`))) { + continue; + } + const v = parseFloat(line.split("}").at(-1)?.trim() ?? ""); + if (!Number.isNaN(v)) sum += v; + } + return sum; +} + +describe("custom guardrail e2e: a broken script is distinguishable from an enforcing policy", () => { + let app: SpawnedApp | undefined; + let upstream: OpenAiUpstream | undefined; + let etcd: EtcdClient | undefined; + let etcdReachable = false; + + beforeAll(async () => { + etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + upstream = await startOpenAiUpstream({ + nonStreamBody: { + id: "cmpl-clean", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "gpt-4o-mini", + choices: [ + { + index: 0, + message: { role: "assistant", content: "a safe and clean reply" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 8, total_tokens: 13 }, + }, + }); + + app = await spawnApp(); + const seed = new SeedClient(etcd, app.etcdPrefix); + const pk = await seed.createProviderKey({ + display_name: "cvd-pk", + secret: "sk-mock", + api_base: `${upstream.baseUrl}/v1`, + }); + + // One model per script, each with its own guardrail attached by model + // scope — the scripts must not shadow each other, and an attachment row + // suppresses the implicit env-wide fallback. + for (const [model, script] of Object.entries(CASES)) { + const m = await seed.createModel({ + display_name: model, + provider: "openai", + model_name: "gpt-4o-mini", + provider_key_id: pk.id, + }); + const guardrail = await seed.createGuardrail({ + name: `${model}-guard`, + enabled: true, + hook_point: "input", + // The release default, and the setting under which the defect + // showed up: a row that refuses what it could not check. + fail_open: false, + kind: "custom", + script, + timeout_ms: 5000, + }); + await etcd.put( + `${app.etcdPrefix}/guardrail_attachments/${randomUUID()}`, + JSON.stringify({ + guardrail_id: guardrail.id, + env_id: randomUUID(), + scope_type: "model", + scope_id: m.id, + priority: 0, + enabled: true, + }), + ); + } + + await seed.createApiKey({ + key_hash: hash(CALLER), + allowed_models: Object.keys(CASES), + }); + }); + + afterAll(async () => { + await app?.exit(); + await upstream?.close(); + }); + + const client = () => + new OpenAI({ + apiKey: CALLER, + baseURL: `${app!.proxyUrl}/v1`, + maxRetries: 0, + }); + + const send = (model: CaseModel, content: string) => + client().chat.completions.create({ + model, + messages: [{ role: "user", content }], + }); + + /** Send and return the error body of the expected 422. */ + const expect422 = async (model: CaseModel, content: string) => { + let caught: unknown; + try { + await send(model, content); + } catch (e) { + caught = e; + } + expect(caught, `${model} should have been refused`).toBeInstanceOf(APIError); + if (!(caught instanceof APIError)) throw new Error("unreachable"); + expect(caught.status).toBe(422); + return caught.error as { message?: string; type?: string; code?: string }; + }; + + const scrape = async () => { + const res = await fetch(`${app!.metricsUrl}/metrics`); + expect(res.status).toBe(200); + return res.text(); + }; + + // Gate on the whole seed being live: the enforcing row must actually + // refuse its marker before any case can be read as a real answer. + const ensureSeedLive = () => + waitConfigPropagation(async () => { + try { + await send("cvd-none", `propagation probe ${BLOCK_MARKER}`); + return false; + } catch (e) { + return e instanceof APIError && e.status === 422; + } + }); + + test("`allow` is accepted as a synonym of `none` and the request goes through", async (ctx) => { + if (!etcdReachable) ctx.skip(); + await ensureSeedLive(); + + const before = upstream!.receivedRequests.length; + const reply = await send("cvd-allow", "what is the capital of France"); + expect(reply.choices[0]?.message?.content).toBe("a safe and clean reply"); + expect(upstream!.receivedRequests.length).toBe(before + 1); + }); + + test("a real policy block keeps the content-policy message and carries no error code", async (ctx) => { + if (!etcdReachable) ctx.skip(); + await ensureSeedLive(); + + const err = await expect422("cvd-none", `please help with ${BLOCK_MARKER}`); + expect(err.type).toBe("content_filter"); + expect(err.message).toBe( + "request blocked by content policy (guardrail 'cvd-none-guard')", + ); + expect(err.code).toBeUndefined(); + }); + + test("an unknown action refuses, but says the guardrail could not evaluate the request", async (ctx) => { + if (!etcdReachable) ctx.skip(); + await ensureSeedLive(); + + const before = upstream!.receivedRequests.length; + const err = await expect422("cvd-unknown", "a perfectly ordinary question"); + // Still fail-closed: unscreened content must not reach the upstream. + expect(upstream!.receivedRequests.length).toBe(before); + // ...but the caller is told this was not a content decision. + expect(err.message).not.toContain("blocked by content policy"); + expect(err.message).toContain("could not evaluate"); + expect(err.message).toContain("cvd-unknown-guard"); + expect(err.message).toContain("custom_unknown_action"); + expect(err.code).toBe("guardrail_unavailable"); + }); + + test("a hook that returns nothing is reported as its own failure mode, not as a typo'd action", async (ctx) => { + if (!etcdReachable) ctx.skip(); + await ensureSeedLive(); + + for (const model of ["cvd-noreturn", "cvd-empty"] as const) { + const err = await expect422(model, "a perfectly ordinary question"); + expect(err.message, model).toContain("could not evaluate"); + expect(err.message, model).toContain("custom_no_verdict"); + expect(err.code, model).toBe("guardrail_unavailable"); + } + }); + + test("the logs name the offending action and the accepted vocabulary", async (ctx) => { + if (!etcdReachable) ctx.skip(); + await ensureSeedLive(); + + await expect422("cvd-unknown", "a perfectly ordinary question"); + const log = app!.output(); + expect(log).toContain("custom guardrail returned an unknown action"); + expect(log).toContain("none | allow | block | mask"); + expect(log).toContain("permit"); + }); + + test("the latency histogram separates the two script faults from a policy block", async (ctx) => { + if (!etcdReachable) ctx.skip(); + await ensureSeedLive(); + + const before = await scrape(); + await expect422("cvd-unknown", "a perfectly ordinary question"); + await expect422("cvd-noreturn", "a perfectly ordinary question"); + await expect422("cvd-none", `please help with ${BLOCK_MARKER}`); + const after = await scrape(); + + for (const [guardrail, errorType] of [ + ["cvd-unknown-guard", "custom_unknown_action"], + ["cvd-noreturn-guard", "custom_no_verdict"], + // A content decision is tagged `none` — the label that separates + // "your script is broken" from "your policy fired". + ["cvd-none-guard", "none"], + ] as const) { + const labels = { guardrail, result: "blocked", error_type: errorType }; + expect( + guardrailCount(after, labels), + `${guardrail} error_type=${errorType}`, + ).toBeGreaterThan(guardrailCount(before, labels)); + } + }); +});