Fail closed on unbounded Guardian parent compactions (#41152)

## Why

Guardian classification must not proceed after silently dropping a parent
compaction that cannot fit within its configured size limit.

## What changed

- Distinguish an absent or unusable parent compaction from serialization and
  size-limit failures.
- Record a fail-closed score and failed classification when the latest encrypted
  parent compaction cannot be serialized or exceeds the complete-item byte
  budget.
- Preserve omission of compactions with missing or empty encrypted content
  without falling back to older context.

## Testing

Cover compactions at the exact byte limit, one byte over it, and oversized
passthrough metadata.

GitOrigin-RevId: 8e607c88f10003cdfab273963bc73c848b09c821
This commit is contained in:
jif authored and copyberry committed 2026-08-27 14:31:57 +00:00
1 parent 5ed334a29f
commit 4f2a1d8666
2 files changed
+42 -50

No files matched your search

@@ -442,41 +442,13 @@ impl GuardianV2Extension {
.enable_image_capture(); .enable_image_capture();
} }
input.thread_store.insert(guardian_config.clone()); input.thread_store.insert(guardian_config.clone());
let latest_parent_compaction = if guardian_config.reuse_parent_compaction { let parent_compaction = if guardian_config.reuse_parent_compaction {
input match encrypted_parent_compaction(
.conversation_history input.conversation_history.items(),
.items()
.filter(|item| {
matches!(
item,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
)
})
.last()
} else {
None
};
let parent_compaction = latest_parent_compaction.and_then(|item| {
encrypted_parent_compaction(
std::iter::once(item),
guardian_config.max_parent_compaction_tokens, guardian_config.max_parent_compaction_tokens,
) ) {
}); Ok(compaction) => compaction,
if parent_compaction.is_none() Err(_) => {
&& latest_parent_compaction.is_some_and(|item| match item {
ResponseItem::Compaction {
id: Some(_),
encrypted_content,
..
}
| ResponseItem::ContextCompaction {
id: Some(_),
encrypted_content: Some(encrypted_content),
..
} => !encrypted_content.is_empty(),
_ => false,
})
{
Self::record_fail_closed_score(input.thread_store, sampled_at); Self::record_fail_closed_score(input.thread_store, sampled_at);
record_classification( record_classification(
metrics.as_deref(), metrics.as_deref(),
@@ -485,6 +457,10 @@ impl GuardianV2Extension {
); );
return; return;
} }
}
} else {
None
};
let call_id = input.call_id.to_owned(); let call_id = input.call_id.to_owned();
let mcp_tool = input.mcp_tool.cloned(); let mcp_tool = input.mcp_tool.cloned();
let action = GuardianAction { let action = GuardianAction {
@@ -754,21 +730,32 @@ impl GuardianV2Extension {
} }
} }
#[derive(Debug, Eq, PartialEq)]
enum ParentCompactionError {
Serialization,
Oversized,
}
// An unusable latest compaction must never fall back to an older one. Missing
// encrypted content can be omitted; content that cannot be bounded rejects the sample.
fn encrypted_parent_compaction<'a>( fn encrypted_parent_compaction<'a>(
items: impl Iterator<Item = &'a ResponseItem>, items: impl Iterator<Item = &'a ResponseItem>,
max_parent_compaction_tokens: usize, max_parent_compaction_tokens: usize,
) -> Option<ResponseItem> { ) -> Result<Option<ResponseItem>, ParentCompactionError> {
let max_compaction_bytes = TruncationPolicy::Tokens(max_parent_compaction_tokens).byte_budget(); let max_compaction_bytes = TruncationPolicy::Tokens(max_parent_compaction_tokens).byte_budget();
let item = items let Some(item) = items
.filter(|item| { .filter(|item| {
matches!( matches!(
item, item,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
) )
}) })
.last()?; .last()
else {
return Ok(None);
};
match item { let encrypted_content = match item {
ResponseItem::Compaction { ResponseItem::Compaction {
id: Some(_), id: Some(_),
encrypted_content, encrypted_content,
@@ -778,14 +765,18 @@ fn encrypted_parent_compaction<'a>(
id: Some(_), id: Some(_),
encrypted_content: Some(encrypted_content), encrypted_content: Some(encrypted_content),
.. ..
} if !encrypted_content.is_empty() } => encrypted_content,
&& serde_json::to_vec(item) _ => return Ok(None),
.is_ok_and(|serialized| serialized.len() <= max_compaction_bytes) => };
{ if encrypted_content.is_empty() {
Some(item.clone()) return Ok(None);
} }
_ => None, let serialized = serde_json::to_vec(item).map_err(|_| ParentCompactionError::Serialization)?;
if serialized.len() > max_compaction_bytes {
return Err(ParentCompactionError::Oversized);
} }
Ok(Some(item.clone()))
} }
/// Installs feature-gated Guardian V2 tool classification for each thread. /// Installs feature-gated Guardian V2 tool classification for each thread.
@@ -57,6 +57,7 @@ use serde_json::json;
use super::GuardianV2Extension; use super::GuardianV2Extension;
use super::GuardianV2ScoreProgress; use super::GuardianV2ScoreProgress;
use super::ParentCompactionError;
use super::StrictReviewReason; use super::StrictReviewReason;
use super::encrypted_parent_compaction; use super::encrypted_parent_compaction;
use super::should_classify_tool; use super::should_classify_tool;
@@ -645,14 +646,14 @@ fn encrypted_parent_compaction_preserves_the_latest_valid_item() {
[&older, &latest].into_iter(), [&older, &latest].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS, DEFAULT_PARENT_COMPACTION_TOKENS,
), ),
Some(latest.clone()) Ok(Some(latest.clone()))
); );
assert_eq!( assert_eq!(
encrypted_parent_compaction( encrypted_parent_compaction(
[&latest, &older].into_iter(), [&latest, &older].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS, DEFAULT_PARENT_COMPACTION_TOKENS,
), ),
Some(older) Ok(Some(older))
); );
} }
@@ -697,7 +698,7 @@ fn encrypted_parent_compaction_rejects_invalid_latest_item() {
[&older, latest].into_iter(), [&older, latest].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS, DEFAULT_PARENT_COMPACTION_TOKENS,
), ),
None, Ok(None),
"an unusable latest summary must not resurrect older context" "an unusable latest summary must not resurrect older context"
); );
} }
@@ -736,7 +737,7 @@ fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
assert_eq!(serde_json::to_vec(&*item)?.len(), max_compaction_bytes); assert_eq!(serde_json::to_vec(&*item)?.len(), max_compaction_bytes);
assert_eq!( assert_eq!(
encrypted_parent_compaction(std::iter::once(&*item), DEFAULT_PARENT_COMPACTION_TOKENS,), encrypted_parent_compaction(std::iter::once(&*item), DEFAULT_PARENT_COMPACTION_TOKENS,),
Some(item.clone()) Ok(Some(item.clone()))
); );
let mut oversized = item.clone(); let mut oversized = item.clone();
@@ -759,7 +760,7 @@ fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
[&*item, &oversized].into_iter(), [&*item, &oversized].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS, DEFAULT_PARENT_COMPACTION_TOKENS,
), ),
None, Err(ParentCompactionError::Oversized),
"an oversized latest summary must not resurrect older context" "an oversized latest summary must not resurrect older context"
); );
} }
@@ -780,7 +781,7 @@ fn encrypted_parent_compaction_rejects_oversized_latest_item() -> Result<()> {
[&bounded[0], &oversized_metadata].into_iter(), [&bounded[0], &oversized_metadata].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS, DEFAULT_PARENT_COMPACTION_TOKENS,
), ),
None, Err(ParentCompactionError::Oversized),
"oversized passthrough metadata must not bypass the complete-item limit" "oversized passthrough metadata must not bypass the complete-item limit"
); );