Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,20 +860,39 @@ pub struct GuardrailMonitorHit {
/// reason, and any upstream detail are never captured (#153 / #932
/// no-leak criterion).
///
/// One entry per `(guardrail_name, hook, action)`: a guardrail that masks
/// forty string leaves of one tool result reports a single entry whose
/// `counts` and `duration_us` are summed across those leaves.
/// One entry per `(guardrail_name, hook, action, error_type)`: a guardrail
/// that masks forty string leaves of one tool result reports a single entry
/// whose `counts` and `duration_us` are summed across those leaves.
///
/// An empty array alongside `guardrail_blocked: true` is a legitimate
/// state, not a contradiction: a streamed response aborted by the
/// guardrail buffer cap is refused without any member returning a verdict,
/// so there is no policy to name. Read the array as "which policies acted,
/// when one did", never as an inverse of the boolean.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GuardrailEnforcedHit {
/// The configured (row) name of the guardrail that fired.
pub guardrail_name: String,
/// Which side it fired on: `input` or `output`.
pub hook: String,
/// What it did: `masked` (content was rewritten and the request
/// continued) or `blocked` (the request was refused).
/// What it did:
///
/// - `masked` — content was rewritten and the request continued.
/// - `blocked` — the guardrail's content policy refused the request.
/// - `blocked_unavailable` — the guardrail could not evaluate the
/// request and its configuration refuses what it cannot check
/// (`fail_open: false`, or a mandatory rule). The request was
/// refused because the check was unavailable, not because the
/// content matched a policy.
pub action: String,
/// Why the check was unavailable, on `blocked_unavailable` only: a
/// short, bounded cause such as `lakera_timeout` or
/// `presidio_5xx`. Empty for every other action.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub error_type: String,
/// detector/rule name → number of spans masked (`masked` only; empty
/// for `blocked`). Same key space as `redacted_entity_counts`.
/// for the two refusal actions). Same key space as
/// `redacted_entity_counts`.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub counts: std::collections::BTreeMap<String, u32>,
/// Wall-clock time this guardrail spent on this hook, in
Expand Down
5 changes: 4 additions & 1 deletion crates/aisix-guardrails/src/aliyun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,10 @@ impl AliyunTextModerationGuardrail {
if fail_open {
GuardrailVerdict::Bypass { reason: tag.into() }
} else {
GuardrailVerdict::block(format!("aliyun text moderation unavailable ({tag})"))
GuardrailVerdict::block_unavailable(
format!("aliyun text moderation unavailable ({tag})"),
tag,
)
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion crates/aisix-guardrails/src/aliyun_ai_guardrail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,10 @@ impl AliyunAiGuardrail {
if fail_open {
GuardrailVerdict::Bypass { reason: tag.into() }
} else {
GuardrailVerdict::block(format!("aliyun AI guardrail unavailable ({tag})"))
GuardrailVerdict::block_unavailable(
format!("aliyun AI guardrail unavailable ({tag})"),
tag,
)
}
}
}
Expand Down
47 changes: 38 additions & 9 deletions crates/aisix-guardrails/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,21 @@ use std::time::Duration;

use aisix_core::models::GuardrailEnforcedHit;

/// Coalescing key: one entry per guardrail row, per hook, per action.
type HitKey = (String, &'static str, &'static str);
/// Coalescing key: one entry per guardrail row, per hook, per action —
/// and, for the one action that carries a cause, per cause. The cause is
/// part of the key rather than merged in because two different causes are
/// two different facts; in practice a member refuses at most once per
/// hook, so the extra dimension adds no entries.
type HitKey = (String, &'static str, &'static str, String);

/// Per-request accumulator of enforced guardrail hits.
///
/// Coalescing matters for correctness, not just tidiness: the redacting
/// hooks run once per JSON string leaf on the `/mcp` path, so a tool
/// result with forty scannable leaves would otherwise produce forty
/// identical entries. Entries merge by `(guardrail_name, hook, action)`,
/// summing per-detector counts and elapsed time.
/// identical entries. Entries merge by
/// `(guardrail_name, hook, action, error_type)`, summing per-detector
/// counts and elapsed time.
#[derive(Debug, Default)]
pub struct GuardrailAuditLog {
hits: Mutex<BTreeMap<HitKey, GuardrailEnforcedHit>>,
Expand All @@ -43,7 +48,10 @@ impl GuardrailAuditLog {
Self::default()
}

/// Record one enforcing outcome. `counts` is empty for `blocked`.
/// Record one enforcing outcome. `counts` is empty for both refusal
/// actions. `error_type` is the bounded cause carried by
/// `blocked_unavailable` and `None` everywhere else
/// (AISIX-Cloud#1365).
///
/// A poisoned lock is swallowed rather than propagated: losing an
/// audit entry is bad, but panicking a request that a guardrail just
Expand All @@ -53,18 +61,26 @@ impl GuardrailAuditLog {
guardrail_name: &str,
hook: &'static str,
action: &'static str,
error_type: Option<&str>,
elapsed: Duration,
counts: &BTreeMap<String, u32>,
) {
let Ok(mut hits) = self.hits.lock() else {
return;
};
let error_type = error_type.unwrap_or_default();
let entry = hits
.entry((guardrail_name.to_owned(), hook, action))
.entry((
guardrail_name.to_owned(),
hook,
action,
error_type.to_owned(),
))
.or_insert_with(|| GuardrailEnforcedHit {
guardrail_name: guardrail_name.to_owned(),
hook: hook.to_owned(),
action: action.to_owned(),
error_type: error_type.to_owned(),
..Default::default()
});
for (detector, n) in counts {
Expand All @@ -76,7 +92,8 @@ impl GuardrailAuditLog {
.saturating_add(elapsed.as_micros().min(u32::MAX as u128) as u32);
}

/// Snapshot the hits recorded so far, in `(name, hook, action)` order.
/// Snapshot the hits recorded so far, in
/// `(name, hook, action, error_type)` order.
///
/// Non-destructive on purpose: a handler that emits more than one
/// usage event per request (a per-attempt event plus the terminal
Expand Down Expand Up @@ -105,13 +122,15 @@ mod tests {
"pii-guard",
"output",
"masked",
None,
Duration::from_micros(40),
&counts(&[("email", 2)]),
);
log.record(
"pii-guard",
"output",
"masked",
None,
Duration::from_micros(60),
&counts(&[("email", 1), ("phone", 3)]),
);
Expand All @@ -133,21 +152,24 @@ mod tests {
"g",
"input",
"masked",
None,
Duration::ZERO,
&counts(&[("ip", 1)]),
);
log.record(
"g",
"output",
"masked",
None,
Duration::ZERO,
&counts(&[("ip", 1)]),
);
log.record("g", "output", "blocked", Duration::ZERO, &none);
log.record("g", "output", "blocked", None, Duration::ZERO, &none);
log.record(
"other",
"input",
"masked",
None,
Duration::ZERO,
&counts(&[("ip", 1)]),
);
Expand Down Expand Up @@ -176,7 +198,14 @@ mod tests {
#[test]
fn snapshot_does_not_drain() {
let log = GuardrailAuditLog::new();
log.record("g", "input", "blocked", Duration::ZERO, &BTreeMap::new());
log.record(
"g",
"input",
"blocked",
None,
Duration::ZERO,
&BTreeMap::new(),
);
assert_eq!(log.snapshot().len(), 1);
assert_eq!(log.snapshot().len(), 1);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-guardrails/src/bedrock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ impl BedrockGuardrail {
reason: reason.into(),
}
} else {
GuardrailVerdict::block(format!("bedrock unavailable ({reason})"))
GuardrailVerdict::block_unavailable(format!("bedrock unavailable ({reason})"), reason)
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -761,9 +761,17 @@ impl MandatoryGuardrail {
// Carry the row name so downstream handlers can name the
// guardrail in the 422 envelope (#519 B.4b) — `block()` would
// drop it to `None` and surface an unnamed content-filter block.
//
// The `Bypass` reason IS the kind's bounded failure tag, so
// this conversion is the one place that knows, structurally,
// that the refusal is an outage rather than a policy
// decision — carry it (AISIX-Cloud#1365). Without it the
// audit trail reports a `mandatory` row's upstream outage
// exactly like a real content violation.
GuardrailVerdict::Block {
reason: format!("mandatory guardrail unavailable: {reason}"),
guardrail_name: Some(self.row_name.clone()),
unavailable: Some(crate::bounded_failure_tag(&reason)),
}
}
other => other,
Expand Down Expand Up @@ -1605,6 +1613,10 @@ mod tests {
GuardrailVerdict::Block {
reason: "mandatory guardrail unavailable: upstream_unreachable".to_string(),
guardrail_name: Some("remote".to_string()),
// AISIX-Cloud#1365: a mandatory row's Bypass→Block is an
// availability failure by construction, so the bypass tag
// survives the conversion.
unavailable: Some("upstream_unreachable".to_string()),
},
"mandatory input Bypass must become a named Block, got {vin:?}",
);
Expand All @@ -1614,6 +1626,10 @@ mod tests {
GuardrailVerdict::Block {
reason: "mandatory guardrail unavailable: upstream_unreachable".to_string(),
guardrail_name: Some("remote".to_string()),
// AISIX-Cloud#1365: a mandatory row's Bypass→Block is an
// availability failure by construction, so the bypass tag
// survives the conversion.
unavailable: Some("upstream_unreachable".to_string()),
},
"mandatory output Bypass must become a named Block, got {vout:?}",
);
Expand Down
Loading