Preserve complete actions in Guardian approval reviews (#44569)

## Why

Truncating action arguments can leave approval reviewers evaluating incomplete actions. Large actions need complete review input and explicit handling when they exceed the review budget.

## What changed

- Remove action truncation and the fixed synchronous action byte limit. Admit complete actions against the whole-request budget, splitting long text losslessly into bounded transport parts and accounting for their framing.
- Route actions exceeding the asynchronous action budget to synchronous review. Prevent cached scores from covering oversized calls, including expanded approval arguments, while allowing later small actions to recover score reuse.
- Request user approval when optional review exhausts its local input budget. Keep required review and compaction service failures closed to approval, and retire exhausted review sessions.

## Testing

Add coverage for complete large-action delivery, optional user fallback, required-review denial, subsequent review recovery, async overflow through MCP approval routing, and lossless text splitting with budget accounting.

GitOrigin-RevId: 08f06e68a94b2f779480dd6b6cf5bc30b241f6dd
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-09-10 16:01:02 +00:00
1 parent 3319d9b296
commit 9c4879f3a5
33 files changed
+841 -547

No files matched your search

@@ -0,0 +1,216 @@
//! Exercises async overflow and score recovery through real MCP approval routing.
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_async_action_requires_sync_review_and_later_scores_recover() -> Result<()> {
skip_if_no_network!(Ok(()));
let state = Arc::new(MockResponsesState::default());
let gates = [Arc::new(Notify::new()), Arc::new(Notify::new())];
let parent_gates = gates.clone();
let oversized = "required argument ".repeat(/*n*/ 100) + "required suffix";
let action_message = oversized.clone();
let oversized_waiting = Arc::new(Notify::new());
let permission_gate = Arc::clone(&gates[1]);
let listener = TcpListener::bind("127.0.0.1:0").await?;
let responses_url = format!("http://{}", listener.local_addr()?);
let router = Router::new()
.route(
"/v1/responses",
get(luna_websocket).post(
move |State(state): State<Arc<MockResponsesState>>, Json(request): Json<Value>| {
let gates = parent_gates.clone();
let oversized = action_message.clone();
async move {
if request.pointer("/client_metadata/x-openai-subagent")
== Some(&json!("guardian"))
{
return parent_response(State(state), Json(request))
.await
.into_response();
}
let index =
state.parent_requests.fetch_add(/*val*/ 1, Ordering::SeqCst);
if index == 1 {
gates[0].notified().await;
}
let actions: &[usize] = match index {
0 => &[0],
1 => &[1, 2],
2 => &[3],
_ => &[],
};
let response_id = format!("response-{index}");
let mut events = vec![responses::ev_response_created(&response_id)];
for &action in actions {
let call_id = format!("action-{action}");
let message = if action == 1 {
oversized.clone()
} else {
format!("small-{action}")
};
events.push(responses::ev_function_call_with_namespace(
&call_id,
&format!("mcp__{TEST_SERVER_NAME}"),
TEST_TOOL_NAME,
&json!({"message": message}).to_string(),
));
}
if actions.is_empty() {
events.push(responses::ev_assistant_message("done", "done"));
}
events.push(responses::ev_completed(&response_id));
(
[(header::CONTENT_TYPE, "text/event-stream")],
responses::sse(events),
)
.into_response()
}
},
),
)
.route(
"/approval-barrier",
post(move |Json(input): Json<Value>| {
let oversized_waiting = Arc::clone(&oversized_waiting);
let permission_gate = Arc::clone(&permission_gate);
async move {
let message = input["tool_input"]["message"].as_str().unwrap_or_default();
if input["hook_event_name"] == "PermissionRequest"
&& message.starts_with("required argument")
{
// Let the later call start scoring only after overflow was recorded.
oversized_waiting.notify_one();
permission_gate.notified().await;
} else if input["hook_event_name"] == "PreToolUse" && message == "small-2" {
oversized_waiting.notified().await;
}
Json(json!({}))
}
}),
)
.with_state(Arc::clone(&state));
let responses_server = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let (mcp_url, mcp_server) = start_mcp_server(/*sensitive_action*/ None).await?;
let codex_home = TempDir::new()?;
let hook_path = codex_home.path().join("approval-barrier.py");
let endpoint = format!("{responses_url}/approval-barrier");
std::fs::write(
&hook_path,
format!(
r#"import sys
import urllib.request
request = urllib.request.Request({endpoint:?}, data=sys.stdin.buffer.read(),
headers={{"Content-Type": "application/json"}})
with urllib.request.urlopen(request, timeout=30) as response:
print(response.read().decode())
"#
),
)?;
let command = serde_json::to_string(&format!("python3 \"{}\"", hook_path.display()))?;
let hooks = ["PreToolUse", "PermissionRequest"]
.map(|event| format!(
"[[hooks.{event}]]\nmatcher = '^mcp__'\n[[hooks.{event}.hooks]]\ntype = 'command'\ncommand = {command}\n"
))
.join("\n");
std::fs::write(codex_home.path().join("requirements.toml"), hooks)?;
let analytics_server = responses::start_mock_server().await;
mount_analytics_capture(&analytics_server, codex_home.path()).await?;
MockResponsesConfig::new(&responses_url)
.with_model(MODEL)
.with_provider_config("supports_websockets = false")
.with_approval_policy("on-request")
.with_root_config(&format!("approvals_reviewer = \"auto_review\"\nchatgpt_base_url = \"{}\"", analytics_server.uri()))
.with_extra_config(&format!(
"[mcp_servers.{TEST_SERVER_NAME}]\nurl = \"{mcp_url}/mcp\"\ndefault_tools_approval_mode = \"prompt\"\n\n[analytics]\nenabled = true\n\n[features.guardianv2]\nenabled = true\nmax_action_tokens = 128\n\n[features.guardianv2.review_scope]\ncomputer_use_only = false"
))
.enable_feature(Feature::GuardianApproval)
.write(codex_home.path())?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(TIMEOUT)
.await?;
let thread = app_server
.start_thread(ThreadStartParams::default())
.await?
.thread;
state.allow_guardian_review.notify_one();
let id = app_server
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![UserInput::Text {
text: USER_CONTEXT.to_owned(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let _: TurnStartResponse = timeout(TIMEOUT, app_server.read_response(id)).await??;
for (index, gate) in gates.iter().enumerate() {
wait_for_luna_request(&state, index).await?;
// The oversized call is still in its permission hook;
// only the seed and the later small call can have begun synchronous review.
wait_for_guardian_reviews(&state, 1 + index).await?;
// First establish a permissive score; after overflow, establish a new one.
state.allow_luna.notify_one();
timeout(TIMEOUT, async {
loop {
let events = captured_analytics_events(&analytics_server).await;
if events
.iter()
.filter(|event| {
event["event_type"] == "codex_guardian_v2_classification"
&& event["event_params"]["outcome"] == "success"
})
.count()
> index
{
break;
}
tokio::time::sleep(Duration::from_millis(/*millis*/ 25)).await;
}
})
.await?;
gate.notify_one();
}
let completed: TurnCompletedNotification =
timeout(TIMEOUT, app_server.read_notification("turn/completed")).await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
wait_for_luna_request(&state, /*index*/ 2).await?;
app_server.shutdown_gracefully().await?;
// First call, oversized call, and recovery call need review. The fourth reuses
// the recovered score. Only the three small actions reach the async model.
assert_eq!(
(
state.guardian_reviews.load(Ordering::SeqCst),
state
.luna_requests
.lock()
.expect("Luna request lock should not be poisoned")
.len()
),
(3, 3)
);
let reviews = state
.guardian_requests
.lock()
.expect("Guardian request lock should not be poisoned");
let action = reviews[2]["input"]
.as_array()
.expect("Guardian request should contain input items")
.iter()
.rev()
.filter_map(|item| item["content"].as_array())
.flatten()
.filter_map(|part| part["text"].as_str())
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
.find(|value| value["tool"] == "mcp_tool_call")
.expect("complete MCP action");
assert_eq!(action["arguments"], json!({"message": oversized}));
mcp_server.abort();
responses_server.abort();
Ok(())
}
@@ -92,6 +92,9 @@ mod policy;
#[path = "guardian_code_mode_tests.rs"] #[path = "guardian_code_mode_tests.rs"]
mod code_mode; mod code_mode;
#[path = "guardian_action_budget_tests.rs"]
mod action_budget;
const TIMEOUT: Duration = Duration::from_secs(30); const TIMEOUT: Duration = Duration::from_secs(30);
const MODEL: &str = "mock-model"; const MODEL: &str = "mock-model";
const REQUIRED_MODEL: &str = "protected-model"; const REQUIRED_MODEL: &str = "protected-model";
+3 -67
View File
@@ -12,13 +12,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri; use codex_utils_path_uri::PathUri;
use serde::Serialize; use serde::Serialize;
use serde::ser::Error as _;
use serde_json::Value; use serde_json::Value;
use super::GUARDIAN_MAX_ACTION_BYTES;
use super::GUARDIAN_MAX_ACTION_STRING_TOKENS;
use super::prompt::guardian_truncate_text;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub(crate) enum GuardianApprovalRequest { pub(crate) enum GuardianApprovalRequest {
ExecCommand { ExecCommand {
@@ -249,49 +244,6 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static
} }
} }
fn truncate_guardian_action_value(value: Value) -> (Value, bool) {
match value {
Value::String(text) => {
let (text, truncated) =
guardian_truncate_text(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS);
(Value::String(text), truncated)
}
Value::Array(values) => {
let mut truncated = false;
let values = values
.into_iter()
.map(|value| {
let (value, value_truncated) = truncate_guardian_action_value(value);
truncated |= value_truncated;
value
})
.collect::<Vec<_>>();
(Value::Array(values), truncated)
}
Value::Object(values) => {
let mut entries = values.into_iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut truncated = false;
let values = entries
.into_iter()
.map(|(key, value)| {
let (value, value_truncated) = truncate_guardian_action_value(value);
truncated |= value_truncated;
(key, value)
})
.collect();
(Value::Object(values), truncated)
}
other => (other, false),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FormattedGuardianAction {
pub(crate) text: String,
pub(crate) truncated: bool,
}
pub(crate) fn guardian_approval_request_to_json( pub(crate) fn guardian_approval_request_to_json(
action: &GuardianApprovalRequest, action: &GuardianApprovalRequest,
) -> serde_json::Result<Value> { ) -> serde_json::Result<Value> {
@@ -584,26 +536,10 @@ pub(crate) fn guardian_request_turn_id<'a>(
pub(crate) fn format_guardian_action_pretty( pub(crate) fn format_guardian_action_pretty(
action: &GuardianApprovalRequest, action: &GuardianApprovalRequest,
) -> serde_json::Result<FormattedGuardianAction> {
let value = guardian_action_for_review(action)?;
let (value, truncated) = truncate_guardian_action_value(value);
let text = enforce_guardian_action_byte_limit(serde_json::to_string_pretty(&value)?)?;
Ok(FormattedGuardianAction { text, truncated })
}
fn enforce_guardian_action_byte_limit(text: String) -> serde_json::Result<String> {
if text.len() > GUARDIAN_MAX_ACTION_BYTES {
return Err(serde_json::Error::custom(format!(
"Guardian action exceeds the {GUARDIAN_MAX_ACTION_BYTES}-byte review limit"
)));
}
Ok(text)
}
pub(crate) fn format_guardian_action_compact(
action: &GuardianApprovalRequest,
) -> serde_json::Result<String> { ) -> serde_json::Result<String> {
enforce_guardian_action_byte_limit(serde_json::to_string(&guardian_action_for_review(action)?)?) let mut value = guardian_action_for_review(action)?;
value.sort_all_objects();
serde_json::to_string_pretty(&value)
} }
fn guardian_action_for_review(action: &GuardianApprovalRequest) -> serde_json::Result<Value> { fn guardian_action_for_review(action: &GuardianApprovalRequest) -> serde_json::Result<Value> {
+6 -8
View File
@@ -79,9 +79,6 @@ pub(crate) async fn decide_approval(
.as_ref() .as_ref()
.is_some_and(CancellationToken::is_cancelled) .is_some_and(CancellationToken::is_cancelled)
|| reasons.retry.is_some() || reasons.retry.is_some()
|| request.request.as_ref().is_ok_and(|request| {
super::approval_request::format_guardian_action_compact(request).is_err()
})
|| matches!(&request.request, Ok(GuardianApprovalRequest::ExecCommand { sandbox_permissions, .. }) || matches!(&request.request, Ok(GuardianApprovalRequest::ExecCommand { sandbox_permissions, .. })
if sandbox_permissions.requires_escalated_permissions()); if sandbox_permissions.requires_escalated_permissions());
let full_access = context.environments().has_full_access( let full_access = context.environments().has_full_access(
@@ -115,7 +112,10 @@ pub(crate) async fn decide_approval(
review_id: review_id.clone(), review_id: review_id.clone(),
request: request.clone(), request: request.clone(),
reasons, reasons,
options, options: GuardianReviewOptions {
require_guardian,
..options
},
}); });
let input = ApprovalDecisionInput { let input = ApprovalDecisionInput {
approval_id: &review_id, approval_id: &review_id,
@@ -179,9 +179,7 @@ pub(crate) async fn decide_approval(
record_guardian_non_denial(&session, turn_id).await; record_guardian_non_denial(&session, turn_id).await;
Some(ReviewDecision::Approved) Some(ReviewDecision::Approved)
} }
Some(ApprovalDecision::Allow) => { Some(ApprovalDecision::Allow) => runtime.review(GuardianReviewReason::FreshRequired).await,
Some(runtime.review(GuardianReviewReason::FreshRequired).await)
}
Some(ApprovalDecision::AskUser) if !require_guardian => None, Some(ApprovalDecision::AskUser) if !require_guardian => None,
None if !require_guardian None if !require_guardian
&& !super::review::routes_approval_policy_to_guardian( && !super::review::routes_approval_policy_to_guardian(
@@ -192,7 +190,7 @@ pub(crate) async fn decide_approval(
None None
} }
None | Some(ApprovalDecision::AskUser) => { None | Some(ApprovalDecision::AskUser) => {
Some(runtime.review(GuardianReviewReason::Policy).await) runtime.review(GuardianReviewReason::Policy).await
} }
} }
} }
+2 -2
View File
@@ -36,8 +36,8 @@ pub(super) async fn record_failed_review(
target_item_id: guardian_request_target_item_id(&params.request), target_item_id: guardian_request_target_item_id(&params.request),
reviewer_thread_id: reviewer.thread_id(), reviewer_thread_id: reviewer.thread_id(),
model: &params.model, model: &params.model,
action: &action.text, action: &action,
action_truncated: action.truncated, action_truncated: false,
instructions: Some(&instructions.text), instructions: Some(&instructions.text),
history: history.raw_items().collect(), history: history.raw_items().collect(),
}); });
+8 -2
View File
@@ -52,7 +52,13 @@ pub(crate) async fn check_pending(session: &Session, turn: &TurnContext) -> Code
}, },
GuardianBudgetOmission.render(), GuardianBudgetOmission.render(),
) )
.map_err(|error| CodexErr::InvalidRequest(error.to_string()))?; .map_err(|_error| {
session
.services
.thread_extension_data
.insert(super::request_budget::ExhaustedReviewBudget::Detected);
CodexErr::ContextWindowExceeded
})?;
} }
Ok(()) Ok(())
} }
@@ -144,7 +150,7 @@ pub(crate) async fn finalize(
session session
.services .services
.thread_extension_data .thread_extension_data
.insert(super::request_budget::ExhaustedReviewBudget); .insert(super::request_budget::ExhaustedReviewBudget::Detected);
match error { match error {
codex_guardian_context::SectionError::EvidenceLimitExceeded { .. } => { codex_guardian_context::SectionError::EvidenceLimitExceeded { .. } => {
CodexErr::ContextWindowExceeded CodexErr::ContextWindowExceeded
-2
View File
@@ -69,8 +69,6 @@ const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = codex_guardian_context::ContextPro
.tool_tokens; .tool_tokens;
pub(crate) const GUARDIAN_MAX_ROOT_MESSAGE_TOKENS: usize = 900; pub(crate) const GUARDIAN_MAX_ROOT_MESSAGE_TOKENS: usize = 900;
pub(crate) const GUARDIAN_MAX_NODE_REPL_TOOL_RESULT_TOKENS: usize = 6_000; pub(crate) const GUARDIAN_MAX_NODE_REPL_TOOL_RESULT_TOKENS: usize = 6_000;
pub(crate) const GUARDIAN_MAX_ACTION_BYTES: usize = 50_000 * 4;
const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000;
/// Captures review inputs from the issuing step without retaining its MCP bindings or tool router. /// Captures review inputs from the issuing step without retaining its MCP bindings or tool router.
/// Background network approvals and Unix interception use the active task's resolved settings. /// Background network approvals and Unix interception use the active task's resolved settings.
+1 -3
View File
@@ -42,7 +42,6 @@ pub(crate) struct GuardianPromptItems {
pub(crate) context: ComposedContext, pub(crate) context: ComposedContext,
pub(crate) transcript_cursor: GuardianTranscriptCursor, pub(crate) transcript_cursor: GuardianTranscriptCursor,
pub(crate) node_repl_evidence_sequence: u64, pub(crate) node_repl_evidence_sequence: u64,
pub(crate) reviewed_action_truncated: bool,
} }
/// Points to the end of the transcript that the guardian has already reviewed. /// Points to the end of the transcript that the guardian has already reviewed.
@@ -120,7 +119,7 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
.fragments; .fragments;
let planned_action_json = format_guardian_action_pretty(&request)?; let planned_action_json = format_guardian_action_pretty(&request)?;
let planned_action = PlannedAction { let planned_action = PlannedAction {
json: planned_action_json.text, json: planned_action_json,
tool_descriptions: if let GuardianApprovalRequest::McpToolCall { tool_descriptions: if let GuardianApprovalRequest::McpToolCall {
tool_description, tool_description,
connector_description, connector_description,
@@ -234,7 +233,6 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
context, context,
transcript_cursor, transcript_cursor,
node_repl_evidence_sequence, node_repl_evidence_sequence,
reviewed_action_truncated: planned_action_json.truncated,
}) })
} }
+6 -2
View File
@@ -19,7 +19,11 @@ pub(super) const INPUT_TOKEN_MARGIN: usize = 256;
// Keep a failed reviewer out of reuse unless compaction and a fresh budget // Keep a failed reviewer out of reuse unless compaction and a fresh budget
// check succeed. Also distinguishes local budget failures from backend errors. // check succeed. Also distinguishes local budget failures from backend errors.
pub(crate) struct ExhaustedReviewBudget; pub(crate) enum ExhaustedReviewBudget {
Detected,
// A compaction service failure must not be mistaken for local exhaustion.
Compacting,
}
pub(crate) fn observe(telemetry: &SessionTelemetry, request: &ResponsesApiRequest) -> usize { pub(crate) fn observe(telemetry: &SessionTelemetry, request: &ResponsesApiRequest) -> usize {
let total = estimate_request_tokens(request); let total = estimate_request_tokens(request);
@@ -74,7 +78,7 @@ pub(crate) fn check_prompt(
session session
.services .services
.thread_extension_data .thread_extension_data
.insert(ExhaustedReviewBudget); .insert(ExhaustedReviewBudget::Detected);
return Err(CodexErr::ContextWindowExceeded); return Err(CodexErr::ContextWindowExceeded);
} }
session session
+4 -2
View File
@@ -116,6 +116,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
let completed = report.complete( let completed = report.complete(
GuardianReviewOutcome::Error(GuardianReviewError::Cancelled), GuardianReviewOutcome::Error(GuardianReviewError::Cancelled),
turn.model_info(), turn.model_info(),
self.options.require_guardian,
GuardianReviewAnalyticsResult::without_session(), GuardianReviewAnalyticsResult::without_session(),
completed_at_ms, completed_at_ms,
); );
@@ -150,7 +151,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
format_guardian_action_pretty(&request).ok().map(|action| { format_guardian_action_pretty(&request).ok().map(|action| {
( (
evidence, evidence,
action.text, action,
authorization_version, authorization_version,
root_authorization_version, root_authorization_version,
) )
@@ -191,7 +192,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
prepared: PreparedApproval, prepared: PreparedApproval,
mut outcome: GuardianReviewOutcome, mut outcome: GuardianReviewOutcome,
analytics_result: GuardianReviewAnalyticsResult, analytics_result: GuardianReviewAnalyticsResult,
) -> ReviewDecision { ) -> Option<ReviewDecision> {
let PreparedApproval { let PreparedApproval {
request: _, request: _,
turn, turn,
@@ -225,6 +226,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
let completed = report.complete( let completed = report.complete(
outcome, outcome,
turn.model_info(), turn.model_info(),
self.options.require_guardian,
analytics_result, analytics_result,
completed_at_ms, completed_at_ms,
); );
+23 -11
View File
@@ -39,7 +39,6 @@ use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem; use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
#[cfg(test)]
use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::Event; use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
@@ -610,7 +609,6 @@ async fn run_review_on_session(
); );
} }
}; };
let reviewed_action_truncated = prompt_items.reviewed_action_truncated;
let transcript_cursor = prompt_items.transcript_cursor; let transcript_cursor = prompt_items.transcript_cursor;
let node_repl_evidence_admission = (prompt_items.node_repl_evidence_sequence let node_repl_evidence_admission = (prompt_items.node_repl_evidence_sequence
> last_admitted_node_repl_response_sequence) > last_admitted_node_repl_response_sequence)
@@ -691,7 +689,6 @@ async fn run_review_on_session(
response_sequence, response_sequence,
}); });
} }
analytics_result.reviewed_action_truncated = reviewed_action_truncated;
let outcome = wait_for_guardian_review( let outcome = wait_for_guardian_review(
review_session, review_session,
@@ -719,14 +716,29 @@ async fn run_review_on_session(
state.prior_review_count = state.prior_review_count.saturating_add(1); state.prior_review_count = state.prior_review_count.saturating_add(1);
state.last_reviewed_transcript_cursor = Some(transcript_cursor); state.last_reviewed_transcript_cursor = Some(transcript_cursor);
} }
let keep_review_session = outcome.1 let budget_exhausted = review_session
&& review_session .session
.session .services
.services .thread_extension_data
.thread_extension_data .remove::<super::request_budget::ExhaustedReviewBudget>();
.remove::<super::request_budget::ExhaustedReviewBudget>() let result = match outcome.0 {
.is_none(); GuardianReviewSessionOutcome::SessionFailed {
(outcome.0, keep_review_session, analytics_result) error_info: Some(CodexErrorInfo::ContextWindowExceeded),
..
} if matches!(
budget_exhausted.as_deref(),
Some(super::request_budget::ExhaustedReviewBudget::Detected)
) =>
{
GuardianReviewSessionOutcome::InputBudgetExceeded
}
result => result,
};
(
result,
outcome.1 && budget_exhausted.is_none(),
analytics_result,
)
} }
async fn ensure_guardian_followup_reminder(review_session: &GuardianReviewSession) { async fn ensure_guardian_followup_reminder(review_session: &GuardianReviewSession) {
-41
View File
@@ -7,7 +7,6 @@ use crate::config::NetworkProxySpec;
use crate::config::PermissionProfileSnapshot; use crate::config::PermissionProfileSnapshot;
use crate::config::test_config; use crate::config::test_config;
use crate::environment_selection::TurnEnvironmentState; use crate::environment_selection::TurnEnvironmentState;
use crate::guardian::approval_request::format_guardian_action_compact;
use crate::guardian::approval_request::guardian_request_target_item_id; use crate::guardian::approval_request::guardian_request_target_item_id;
use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY; use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY;
use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE; use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE;
@@ -1130,46 +1129,6 @@ fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() {
assert!(was_truncated); assert!(was_truncated);
} }
#[test]
fn guardian_action_formatters_reject_large_aggregate_payloads() {
let file: PathUri = test_path_buf("/tmp/file").abs().into();
let action = GuardianApprovalRequest::ApplyPatch {
id: "patch-1".to_string(),
cwd: test_path_buf("/tmp").abs().into(),
files: vec![file; 20_000],
patch: String::new(),
};
for error in [
format_guardian_action_pretty(&action).map(|_| ()),
format_guardian_action_compact(&action).map(|_| ()),
] {
assert_eq!(
error
.expect_err("aggregate action should exceed the review limit")
.to_string(),
"Guardian action exceeds the 200000-byte review limit"
);
}
}
#[test]
fn format_guardian_action_pretty_reports_no_truncation_for_small_payload() -> serde_json::Result<()>
{
let action = GuardianApprovalRequest::ApplyPatch {
id: "patch-1".to_string(),
cwd: test_path_buf("/tmp").abs().into(),
files: Vec::new(),
patch: "line\n".to_string(),
};
let rendered = format_guardian_action_pretty(&action)?;
assert!(rendered.text.contains("\"tool\": \"apply_patch\""));
assert!(!rendered.truncated);
Ok(())
}
#[test] #[test]
fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json::Result<()> { fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json::Result<()> {
let action = GuardianApprovalRequest::McpToolCall { let action = GuardianApprovalRequest::McpToolCall {
+6
View File
@@ -333,6 +333,9 @@ pub(crate) async fn run_turn(
} }
// Incoming evidence can overflow even below the normal history // Incoming evidence can overflow even below the normal history
// threshold. Keep it pending while compacting, then select once more. // threshold. Keep it pending while compacting, then select once more.
sess.services
.thread_extension_data
.insert(crate::guardian::ExhaustedReviewBudget::Compacting);
run_auto_compact( run_auto_compact(
&sess, &sess,
Arc::clone(&first_step_context), Arc::clone(&first_step_context),
@@ -704,6 +707,9 @@ pub(crate) async fn run_turn(
// token-budget resets must fail closed and retire the reviewer. // token-budget resets must fail closed and retire the reviewer.
// Retry once per model step, so ineffective compaction cannot loop. // Retry once per model step, so ineffective compaction cannot loop.
guardian_budget_compacted = true; guardian_budget_compacted = true;
sess.services
.thread_extension_data
.insert(crate::guardian::ExhaustedReviewBudget::Compacting);
run_auto_compact( run_auto_compact(
&sess, &sess,
Arc::clone(&step_context), Arc::clone(&step_context),
@@ -865,11 +865,9 @@ impl UnifiedExecProcessManager {
.map_err(|_| unreviewable_input_error())?; .map_err(|_| unreviewable_input_error())?;
// Bound the entire serialized action plus its reason, including JSON // Bound the entire serialized action plus its reason, including JSON
// escaping. Reject, never execute an unreviewed tail. // escaping. Reject, never execute an unreviewed tail.
let oversized = reviewed.text.len().saturating_add(approval_reason.len()) let oversized =
> MAX_STDIN_APPROVAL_BYTES; reviewed.len().saturating_add(approval_reason.len()) > MAX_STDIN_APPROVAL_BYTES;
let size_check_result = if reviewed.truncated { let size_check_result = if oversized {
"formatter_truncated"
} else if oversized {
"over_limit" "over_limit"
} else { } else {
"within_limit" "within_limit"
@@ -884,7 +882,7 @@ impl UnifiedExecProcessManager {
/*inc*/ 1, /*inc*/ 1,
&[("result", size_check_result), ("input_kind", input_kind)], &[("result", size_check_result), ("input_kind", input_kind)],
); );
if reviewed.truncated || oversized { if oversized {
return Err(unreviewable_input_error()); return Err(unreviewable_input_error());
} }
let approval_context = ApprovalContext { let approval_context = ApprovalContext {
@@ -2,6 +2,8 @@
//! Parent token-budget mode must not replace Guardian's summary compaction. //! Parent token-budget mode must not replace Guardian's summary compaction.
use anyhow::Result; use anyhow::Result;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_core::TurnInputRequest;
use codex_core::config::Constrained; use codex_core::config::Constrained;
use codex_core::config::CurrentTimeReminderConfig; use codex_core::config::CurrentTimeReminderConfig;
use codex_core::config::RolloutBudgetConfig; use codex_core::config::RolloutBudgetConfig;
@@ -9,6 +11,10 @@ use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::openai_models::AutoReviewMessages; use codex_protocol::openai_models::AutoReviewMessages;
use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::user_input::UserInput;
use core_test_support::responses; use core_test_support::responses;
use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed;
@@ -19,6 +25,7 @@ use core_test_support::responses::sse;
use core_test_support::skip_if_no_network; use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec; use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::test_codex; use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::json; use serde_json::json;
use test_case::test_case; use test_case::test_case;
@@ -28,6 +35,7 @@ enum ReviewerResponse {
Decision, Decision,
ToolContinuation, ToolContinuation,
UncompactableContinuation, UncompactableContinuation,
CompactionError,
NextReview, NextReview,
} }
@@ -35,6 +43,7 @@ enum ReviewerResponse {
#[test_case(1, ReviewerResponse::Decision; "required_context_fails_closed")] #[test_case(1, ReviewerResponse::Decision; "required_context_fails_closed")]
#[test_case(4_500, ReviewerResponse::ToolContinuation; "oversized_tool_continuation_compacts")] #[test_case(4_500, ReviewerResponse::ToolContinuation; "oversized_tool_continuation_compacts")]
#[test_case(4_500, ReviewerResponse::UncompactableContinuation; "ineffective_compaction_fails_closed")] #[test_case(4_500, ReviewerResponse::UncompactableContinuation; "ineffective_compaction_fails_closed")]
#[test_case(4_500, ReviewerResponse::CompactionError; "compaction_service_error_does_not_request_user_approval")]
#[test_case(6_000, ReviewerResponse::NextReview; "incoming_review_compacts_existing_history")] #[test_case(6_000, ReviewerResponse::NextReview; "incoming_review_compacts_existing_history")]
async fn review_respects_complete_context_budget( async fn review_respects_complete_context_budget(
window: i64, window: i64,
@@ -47,6 +56,15 @@ async fn review_respects_complete_context_budget(
); );
let server = responses::start_mock_server().await; let server = responses::start_mock_server().await;
let mut builder = test_codex() let mut builder = test_codex()
.with_cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
if matches!(reviewer_response, ReviewerResponse::CompactionError) {
""
} else {
"[auto_review]\nrequired_on_models = [\"gpt-5.5\"]\n"
},
),
)
.with_model_info_override("gpt-5.6-luna", move |model| { .with_model_info_override("gpt-5.6-luna", move |model| {
model.context_window = Some(window); model.context_window = Some(window);
model model
@@ -85,7 +103,9 @@ async fn review_respects_complete_context_budget(
}); });
if matches!( if matches!(
reviewer_response, reviewer_response,
ReviewerResponse::ToolContinuation | ReviewerResponse::UncompactableContinuation ReviewerResponse::ToolContinuation
| ReviewerResponse::UncompactableContinuation
| ReviewerResponse::CompactionError
) { ) {
builder = builder builder = builder
.with_code_mode_host_program(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?); .with_code_mode_host_program(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?);
@@ -131,7 +151,8 @@ async fn review_respects_complete_context_budget(
.to_string(), .to_string(),
), ),
ReviewerResponse::ToolContinuation ReviewerResponse::ToolContinuation
| ReviewerResponse::UncompactableContinuation => ev_custom_tool_call( | ReviewerResponse::UncompactableContinuation
| ReviewerResponse::CompactionError => ev_custom_tool_call(
"reviewer-inspect", "reviewer-inspect",
"exec", "exec",
"text('inspection-output'.repeat(600));", "text('inspection-output'.repeat(600));",
@@ -155,7 +176,9 @@ async fn review_respects_complete_context_budget(
} }
if matches!( if matches!(
reviewer_response, reviewer_response,
ReviewerResponse::UncompactableContinuation | ReviewerResponse::NextReview ReviewerResponse::UncompactableContinuation
| ReviewerResponse::NextReview
| ReviewerResponse::CompactionError
) { ) {
events.extend([ events.extend([
sse(vec![ sse(vec![
@@ -175,7 +198,10 @@ async fn review_respects_complete_context_budget(
]), ]),
]); ]);
} }
if !matches!(reviewer_response, ReviewerResponse::Decision) { if !matches!(
reviewer_response,
ReviewerResponse::Decision | ReviewerResponse::CompactionError
) {
let summary = if matches!( let summary = if matches!(
reviewer_response, reviewer_response,
ReviewerResponse::UncompactableContinuation ReviewerResponse::UncompactableContinuation
@@ -200,7 +226,19 @@ async fn review_respects_complete_context_budget(
]), ]),
); );
} }
let responses = responses::mount_sse_sequence(&server, events).await; let mut events = events
.into_iter()
.map(responses::sse_response)
.collect::<Vec<_>>();
if matches!(reviewer_response, ReviewerResponse::CompactionError) {
events.insert(
/*index*/ 2,
wiremock::ResponseTemplate::new(400).set_body_json(json!({
"error": {"code": "context_length_exceeded", "message": "compaction service context window exceeded"}
})),
);
}
let responses = responses::mount_response_sequence(&server, events).await;
test.submit_text_turn("Run the command if the approval reviewer allows it.") test.submit_text_turn("Run the command if the approval reviewer allows it.")
.await?; .await?;
let (compact_requests, requests): (Vec<_>, Vec<_>) = responses let (compact_requests, requests): (Vec<_>, Vec<_>) = responses
@@ -218,9 +256,7 @@ async fn review_respects_complete_context_budget(
assert!(guardian_requests.is_empty()); assert!(guardian_requests.is_empty());
let output = requests[1].function_call_output("exec-over-budget"); let output = requests[1].function_call_output("exec-over-budget");
assert!( assert!(
output output.to_string().contains("reviewer input budget"),
.to_string()
.contains("evidence exceeds limits for section request_budget"),
"expected required-evidence budget rejection: {output}" "expected required-evidence budget rejection: {output}"
); );
} else { } else {
@@ -264,12 +300,20 @@ async fn review_respects_complete_context_budget(
.expect("parent continues after the rejected review") .expect("parent continues after the rejected review")
.function_call_output("exec-over-budget"); .function_call_output("exec-over-budget");
assert!( assert!(
output output.to_string().contains("reviewer input budget"),
.to_string()
.contains("Codex ran out of room in the model's context window"),
"expected context-budget rejection: {output}" "expected context-budget rejection: {output}"
); );
} }
if matches!(reviewer_response, ReviewerResponse::CompactionError) {
assert!(
requests
.last()
.expect("parent resumes after review")
.function_call_output("exec-over-budget")
.to_string()
.contains("context window")
);
}
let request = guardian_requests[0]; let request = guardian_requests[0];
let context = request.message_input_texts("user").join("\n"); let context = request.message_input_texts("user").join("\n");
assert!(context.contains("<guardian_context_omission>")); assert!(context.contains("<guardian_context_omission>"));
@@ -281,7 +325,9 @@ async fn review_respects_complete_context_budget(
assert!(developer_context.contains("<rollout_budget>")); assert!(developer_context.contains("<rollout_budget>"));
if matches!( if matches!(
reviewer_response, reviewer_response,
ReviewerResponse::UncompactableContinuation | ReviewerResponse::NextReview ReviewerResponse::UncompactableContinuation
| ReviewerResponse::NextReview
| ReviewerResponse::CompactionError
) { ) {
let instruction = if matches!(reviewer_response, ReviewerResponse::NextReview) { let instruction = if matches!(reviewer_response, ReviewerResponse::NextReview) {
"New instructions: retry the requested command and keep all files private. " "New instructions: retry the requested command and keep all files private. "
@@ -345,3 +391,183 @@ async fn review_respects_complete_context_budget(
test.codex.shutdown_and_wait().await?; test.codex.shutdown_and_wait().await?;
Ok(()) Ok(())
} }
#[derive(Clone, Copy)]
enum OversizedActionReview {
Fits,
UserFallback,
RequiredGuardian,
}
#[test_case(OversizedActionReview::Fits; "large_action_receives_automatic_review")]
#[test_case(OversizedActionReview::UserFallback; "optional_review_requests_user_approval")]
#[test_case(OversizedActionReview::RequiredGuardian; "required_review_rejects_incomplete_action")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_action_preserves_review_policy_and_next_review(
review: OversizedActionReview,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_wine_exec!(
Ok(()),
"Guardian approval actions require host-native paths"
);
let server = responses::start_mock_server().await;
let mut builder = test_codex()
.with_model_info_override("gpt-5.5", |model| {
model.auto_review_model_override = Some("gpt-5.6-luna".to_owned());
})
.with_model_info_override("gpt-5.6-luna", move |model| {
model.context_window = Some(if matches!(review, OversizedActionReview::Fits) {
160_000
} else {
16_000
});
})
.with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
});
if matches!(review, OversizedActionReview::RequiredGuardian) {
builder = builder.with_model("gpt-5.5").with_cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
"[auto_review]\nrequired_on_models = [\"gpt-5.5\"]\n",
),
);
}
let test = builder.build_with_auto_env(&server).await?;
let oversized_command =
"echo ".to_owned() + &"large-action".repeat(/*n*/ 20_000) + "required suffix";
let oversized = json!({
"cmd": oversized_command,
"sandbox_permissions": "require_escalated",
"justification": "Run the requested command.",
})
.to_string();
let next = json!({
"cmd": "echo complete-action-review",
"sandbox_permissions": "require_escalated",
"justification": "Run the requested command.",
})
.to_string();
let mut events = vec![
sse(vec![
ev_function_call("oversized", "exec_command", &oversized),
ev_completed("oversized"),
]),
sse(vec![
ev_function_call("next", "exec_command", &next),
ev_completed("next"),
]),
sse(vec![
ev_assistant_message("decision", r#"{"outcome":"allow"}"#),
ev_completed("review"),
]),
sse(vec![
ev_assistant_message("done", "done"),
ev_completed("done"),
]),
];
if matches!(review, OversizedActionReview::Fits) {
// A real policy denial must not fall through to a user prompt. Avoid
// executing the huge command while asserting its complete review input.
events.insert(
/*index*/ 1,
sse(vec![
ev_assistant_message(
"decision",
r#"{"outcome":"deny","rationale":"denied by classifier"}"#,
),
ev_completed("large-review"),
]),
);
}
let response = responses::mount_sse_sequence(&server, events).await;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Run the commands if the approval reviewer allows them.".to_owned(),
text_elements: Vec::new(),
}]))
.await?;
let mut terminal_assessment = None;
let event = wait_for_event(&test.codex, |event| {
if let EventMsg::GuardianAssessment(assessment) = event
&& assessment.status != codex_protocol::protocol::GuardianAssessmentStatus::InProgress
{
terminal_assessment = Some((assessment.status, assessment.risk_level));
}
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
if let EventMsg::ExecApprovalRequest(approval) = event {
assert!(
matches!(review, OversizedActionReview::UserFallback),
"only exhausted optional review may ask the user"
);
assert_eq!(
terminal_assessment,
Some((
codex_protocol::protocol::GuardianAssessmentStatus::Aborted,
None
))
);
assert_eq!(approval.command.last(), Some(&oversized_command));
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::denied("rejected by user"),
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
} else {
assert!(
!matches!(review, OversizedActionReview::UserFallback),
"exhausted optional review must request user approval"
);
}
let requests = response.requests();
let fits = matches!(review, OversizedActionReview::Fits);
assert_eq!(requests.len(), if fits { 5 } else { 4 });
assert!(
requests[if fits { 2 } else { 1 }]
.function_call_output("oversized")
.to_string()
.contains(match review {
OversizedActionReview::Fits => "denied by classifier",
OversizedActionReview::UserFallback => "rejected by user",
OversizedActionReview::RequiredGuardian => "reviewer input budget",
})
);
let reviews = requests
.iter()
.filter(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian")
.collect::<Vec<_>>();
assert_eq!(reviews.len(), if fits { 2 } else { 1 });
if fits {
let parts = reviews[0].message_input_texts("user");
assert!(parts.concat().contains(&oversized_command));
assert!(parts.iter().all(|part| part.len() <= 36_000));
}
let context = reviews
.last()
.expect("review of the next action")
.message_input_texts("user")
.concat();
assert!(context.contains("echo complete-action-review"));
assert!(
requests
.last()
.expect("parent resumes after review")
.function_call_output("next")
.to_string()
.contains("complete-action-review")
);
test.codex.shutdown_and_wait().await?;
Ok(())
}
@@ -63,12 +63,13 @@ pub enum ApprovalDecision {
} }
/// Runs an extension-owned synchronous review already bound to an action by the host. /// Runs an extension-owned synchronous review already bound to an action by the host.
/// Implementations must not resolve approval routing or reuse an async score. /// Implementations must not reuse an async score. `None` requests the host user
/// flow when automatic review exhausts its input budget and host policy permits it.
pub trait SynchronousApprovalReviewer: Send + Sync { pub trait SynchronousApprovalReviewer: Send + Sync {
fn review( fn review(
&self, &self,
reason: codex_protocol::approvals::GuardianReviewReason, reason: codex_protocol::approvals::GuardianReviewReason,
) -> crate::ExtensionFuture<'_, codex_protocol::protocol::ReviewDecision>; ) -> crate::ExtensionFuture<'_, Option<codex_protocol::protocol::ReviewDecision>>;
} }
/// Inputs to Guardian's policy choice. Conversation and scores stay thread-owned. /// Inputs to Guardian's policy choice. Conversation and scores stay thread-owned.
+3 -3
View File
@@ -168,10 +168,10 @@ impl codex_extension_api::SynchronousApprovalReviewer for AllContributors {
fn review( fn review(
&self, &self,
_reason: codex_protocol::approvals::GuardianReviewReason, _reason: codex_protocol::approvals::GuardianReviewReason,
) -> ExtensionFuture<'_, codex_protocol::protocol::ReviewDecision> { ) -> ExtensionFuture<'_, Option<codex_protocol::protocol::ReviewDecision>> {
Box::pin(std::future::ready( Box::pin(std::future::ready(Some(
codex_protocol::protocol::ReviewDecision::Approved, codex_protocol::protocol::ReviewDecision::Approved,
)) )))
} }
} }
@@ -32,6 +32,8 @@ const TIMEOUT_INSTRUCTIONS: &str = concat!(
"Do not assume the action is unsafe based on the timeout alone. ", "Do not assume the action is unsafe based on the timeout alone. ",
"You may retry once, or ask the user for guidance or explicit approval.", "You may retry once, or ask the user for guidance or explicit approval.",
); );
const INPUT_BUDGET_MESSAGE: &str =
"the complete action and minimum review context exceed the reviewer input budget";
pub fn guardian_timeout_message(model_info: &ModelInfo) -> String { pub fn guardian_timeout_message(model_info: &ModelInfo) -> String {
model_info model_info
@@ -44,7 +46,8 @@ pub fn guardian_timeout_message(model_info: &ModelInfo) -> String {
} }
pub struct ReviewCompletion { pub struct ReviewCompletion {
pub decision: ReviewDecision, /// `None` requests user approval after optional review exhausts its input budget.
pub decision: Option<ReviewDecision>,
pub event: GuardianAssessmentEvent, pub event: GuardianAssessmentEvent,
pub warning: Option<String>, pub warning: Option<String>,
pub analytics: GuardianReviewAnalyticsResult, pub analytics: GuardianReviewAnalyticsResult,
@@ -55,6 +58,7 @@ pub struct ReviewCompletion {
pub fn complete_review( pub fn complete_review(
outcome: GuardianReviewOutcome, outcome: GuardianReviewOutcome,
model: &ModelInfo, model: &ModelInfo,
require_guardian: bool,
mut event: GuardianAssessmentEvent, mut event: GuardianAssessmentEvent,
mut analytics: GuardianReviewAnalyticsResult, mut analytics: GuardianReviewAnalyticsResult,
) -> ReviewCompletion { ) -> ReviewCompletion {
@@ -83,6 +87,20 @@ pub fn complete_review(
} }
GuardianReviewOutcome::Error(error) => { GuardianReviewOutcome::Error(error) => {
analytics.failure_reason = Some(error.failure_reason()); analytics.failure_reason = Some(error.failure_reason());
if matches!(error, GuardianReviewError::InputBudgetExceeded) && !require_guardian {
let rationale = format!("Automatic approval review failed: {INPUT_BUDGET_MESSAGE}");
analytics.decision = GuardianReviewDecision::Aborted;
analytics.terminal_status = GuardianReviewTerminalStatus::Aborted;
event.status = GuardianAssessmentStatus::Aborted;
event.rationale = Some(rationale.clone());
return ReviewCompletion {
decision: None,
event,
warning: Some(rationale),
analytics,
assessment_outcome: None,
};
}
match error { match error {
GuardianReviewError::Timeout => { GuardianReviewError::Timeout => {
let rationale = "Automatic approval review timed out while evaluating the requested approval.".to_string(); let rationale = "Automatic approval review timed out while evaluating the requested approval.".to_string();
@@ -91,7 +109,7 @@ pub fn complete_review(
event.status = GuardianAssessmentStatus::TimedOut; event.status = GuardianAssessmentStatus::TimedOut;
event.rationale = Some(rationale.clone()); event.rationale = Some(rationale.clone());
return ReviewCompletion { return ReviewCompletion {
decision: ReviewDecision::TimedOut, decision: Some(ReviewDecision::TimedOut),
event, event,
warning: Some(rationale), warning: Some(rationale),
analytics, analytics,
@@ -103,16 +121,26 @@ pub fn complete_review(
analytics.terminal_status = GuardianReviewTerminalStatus::Aborted; analytics.terminal_status = GuardianReviewTerminalStatus::Aborted;
event.status = GuardianAssessmentStatus::Aborted; event.status = GuardianAssessmentStatus::Aborted;
return ReviewCompletion { return ReviewCompletion {
decision: ReviewDecision::Abort, decision: Some(ReviewDecision::Abort),
event, event,
warning: None, warning: None,
analytics, analytics,
assessment_outcome: None, assessment_outcome: None,
}; };
} }
GuardianReviewError::PromptBuild { message } GuardianReviewError::InputBudgetExceeded
| GuardianReviewError::Session { message, .. } | GuardianReviewError::PromptBuild { .. }
| GuardianReviewError::Parse { message } => { | GuardianReviewError::Session { .. }
| GuardianReviewError::Parse { .. } => {
let message = match &error {
GuardianReviewError::InputBudgetExceeded => INPUT_BUDGET_MESSAGE,
GuardianReviewError::PromptBuild { message }
| GuardianReviewError::Session { message, .. }
| GuardianReviewError::Parse { message } => message,
GuardianReviewError::Timeout | GuardianReviewError::Cancelled => {
"guardian review failed"
}
};
analytics.decision = GuardianReviewDecision::Denied; analytics.decision = GuardianReviewDecision::Denied;
analytics.terminal_status = GuardianReviewTerminalStatus::FailedClosed; analytics.terminal_status = GuardianReviewTerminalStatus::FailedClosed;
let rationale = format!("Automatic approval review failed: {message}"); let rationale = format!("Automatic approval review failed: {message}");
@@ -120,9 +148,9 @@ pub fn complete_review(
event.status = GuardianAssessmentStatus::Denied; event.status = GuardianAssessmentStatus::Denied;
event.rationale = Some(rationale.clone()); event.rationale = Some(rationale.clone());
return ReviewCompletion { return ReviewCompletion {
decision: ReviewDecision::denied(format!( decision: Some(ReviewDecision::denied(format!(
"{rationale}\n{REVIEW_FAILURE_INSTRUCTIONS}" "{rationale}\n{REVIEW_FAILURE_INSTRUCTIONS}"
)), ))),
event, event,
warning: Some(rationale), warning: Some(rationale),
analytics, analytics,
@@ -177,7 +205,7 @@ pub fn complete_review(
)) ))
}; };
ReviewCompletion { ReviewCompletion {
decision, decision: Some(decision),
event, event,
warning: Some(warning), warning: Some(warning),
analytics, analytics,
@@ -74,6 +74,7 @@ impl<'a> FailedReviewFeedback<'a> {
GuardianReviewSessionOutcome::Completed(Ok(None)) => ("missing_decision", None), GuardianReviewSessionOutcome::Completed(Ok(None)) => ("missing_decision", None),
GuardianReviewSessionOutcome::Completed(Err(_)) GuardianReviewSessionOutcome::Completed(Err(_))
| GuardianReviewSessionOutcome::PromptBuildFailed(_) | GuardianReviewSessionOutcome::PromptBuildFailed(_)
| GuardianReviewSessionOutcome::InputBudgetExceeded
| GuardianReviewSessionOutcome::SessionFailed { .. } => ("failed", None), | GuardianReviewSessionOutcome::SessionFailed { .. } => ("failed", None),
GuardianReviewSessionOutcome::TimedOut => ("timed_out", None), GuardianReviewSessionOutcome::TimedOut => ("timed_out", None),
GuardianReviewSessionOutcome::Aborted => ("aborted", None), GuardianReviewSessionOutcome::Aborted => ("aborted", None),
@@ -13,6 +13,7 @@ pub enum GuardianReviewOutcome {
#[derive(Debug)] #[derive(Debug)]
pub enum GuardianReviewError { pub enum GuardianReviewError {
InputBudgetExceeded,
PromptBuild { PromptBuild {
message: String, message: String,
}, },
@@ -61,7 +62,9 @@ impl GuardianReviewError {
pub fn failure_reason(&self) -> GuardianReviewFailureReason { pub fn failure_reason(&self) -> GuardianReviewFailureReason {
match self { match self {
Self::PromptBuild { .. } => GuardianReviewFailureReason::PromptBuildError, Self::PromptBuild { .. } => GuardianReviewFailureReason::PromptBuildError,
Self::Session { .. } => GuardianReviewFailureReason::SessionError, Self::Session { .. } | Self::InputBudgetExceeded => {
GuardianReviewFailureReason::SessionError
}
Self::Parse { .. } => GuardianReviewFailureReason::ParseError, Self::Parse { .. } => GuardianReviewFailureReason::ParseError,
Self::Timeout => GuardianReviewFailureReason::Timeout, Self::Timeout => GuardianReviewFailureReason::Timeout,
Self::Cancelled => GuardianReviewFailureReason::Cancelled, Self::Cancelled => GuardianReviewFailureReason::Cancelled,
@@ -73,6 +76,7 @@ impl GuardianReviewError {
pub enum GuardianReviewSessionOutcome { pub enum GuardianReviewSessionOutcome {
Completed(anyhow::Result<Option<String>>), Completed(anyhow::Result<Option<String>>),
PromptBuildFailed(anyhow::Error), PromptBuildFailed(anyhow::Error),
InputBudgetExceeded,
SessionFailed { SessionFailed {
error: anyhow::Error, error: anyhow::Error,
error_info: Option<CodexErrorInfo>, error_info: Option<CodexErrorInfo>,
@@ -102,6 +106,9 @@ impl From<GuardianReviewSessionOutcome> for GuardianReviewOutcome {
GuardianReviewSessionOutcome::PromptBuildFailed(error) => { GuardianReviewSessionOutcome::PromptBuildFailed(error) => {
Self::Error(GuardianReviewError::prompt_build(error)) Self::Error(GuardianReviewError::prompt_build(error))
} }
GuardianReviewSessionOutcome::InputBudgetExceeded => {
Self::Error(GuardianReviewError::InputBudgetExceeded)
}
GuardianReviewSessionOutcome::SessionFailed { GuardianReviewSessionOutcome::SessionFailed {
error, error,
error_info, error_info,
@@ -92,13 +92,14 @@ impl ReviewReport {
&self, &self,
outcome: GuardianReviewOutcome, outcome: GuardianReviewOutcome,
model: &ModelInfo, model: &ModelInfo,
require_guardian: bool,
analytics: GuardianReviewAnalyticsResult, analytics: GuardianReviewAnalyticsResult,
completed_at_ms: i64, completed_at_ms: i64,
) -> ReviewCompletion { ) -> ReviewCompletion {
let mut event = self.started.clone(); let mut event = self.started.clone();
event.completed_at_ms = Some(completed_at_ms); event.completed_at_ms = Some(completed_at_ms);
event.decision_source = Some(GuardianAssessmentDecisionSource::Agent); event.decision_source = Some(GuardianAssessmentDecisionSource::Agent);
crate::complete_review(outcome, model, event, analytics) crate::complete_review(outcome, model, require_guardian, event, analytics)
} }
pub fn track( pub fn track(
+2 -1
View File
@@ -110,7 +110,8 @@ fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool {
}, },
GuardianReviewOutcome::Completed(_) GuardianReviewOutcome::Completed(_)
| GuardianReviewOutcome::Error( | GuardianReviewOutcome::Error(
GuardianReviewError::PromptBuild { .. } GuardianReviewError::InputBudgetExceeded
| GuardianReviewError::PromptBuild { .. }
| GuardianReviewError::Session { | GuardianReviewError::Session {
error_info: None, .. error_info: None, ..
} }
@@ -71,6 +71,10 @@ fn guardian_review_retry_only_retries_transient_session_and_parse_errors() {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
outcomes.extend([ outcomes.extend([
(GuardianReviewOutcome::Completed(assessment), false), (GuardianReviewOutcome::Completed(assessment), false),
(
GuardianReviewOutcome::Error(GuardianReviewError::InputBudgetExceeded),
false,
),
( (
GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(anyhow::anyhow!( GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(anyhow::anyhow!(
"prompt" "prompt"
+4 -3
View File
@@ -17,6 +17,7 @@ use crate::GuardianReviewSessionLimits;
/// Runtime operations bound to one immutable approval action and its issuing context. /// Runtime operations bound to one immutable approval action and its issuing context.
/// Preparation captures trusted evidence. Completion must invalidate stale approvals /// Preparation captures trusted evidence. Completion must invalidate stale approvals
/// and may only satisfy the review gate, never expand the action's execution authority. /// and may only satisfy the review gate, never expand the action's execution authority.
/// Completion may return `None` for user approval only when host policy permits it.
pub trait ReviewHost: Send + Sync { pub trait ReviewHost: Send + Sync {
type Prepared: Send + Sync; type Prepared: Send + Sync;
@@ -36,7 +37,7 @@ pub trait ReviewHost: Send + Sync {
prepared: Self::Prepared, prepared: Self::Prepared,
outcome: GuardianReviewOutcome, outcome: GuardianReviewOutcome,
analytics: GuardianReviewAnalyticsResult, analytics: GuardianReviewAnalyticsResult,
) -> impl Future<Output = ReviewDecision> + Send; ) -> impl Future<Output = Option<ReviewDecision>> + Send;
} }
/// One review bound by the host before Guardian's approval policy chooses to run it. /// One review bound by the host before Guardian's approval policy chooses to run it.
@@ -51,12 +52,12 @@ impl<H: ReviewHost> SynchronousReview<H> {
} }
impl<H: ReviewHost> SynchronousApprovalReviewer for SynchronousReview<H> { impl<H: ReviewHost> SynchronousApprovalReviewer for SynchronousReview<H> {
fn review(&self, reason: GuardianReviewReason) -> ExtensionFuture<'_, ReviewDecision> { fn review(&self, reason: GuardianReviewReason) -> ExtensionFuture<'_, Option<ReviewDecision>> {
Box::pin(async move { Box::pin(async move {
let deadline = Instant::now() + crate::REVIEW_TIMEOUT; let deadline = Instant::now() + crate::REVIEW_TIMEOUT;
let prepared = match self.host.prepare(reason, deadline).await { let prepared = match self.host.prepare(reason, deadline).await {
Ok(prepared) => prepared, Ok(prepared) => prepared,
Err(decision) => return decision, Err(decision) => return Some(decision),
}; };
let (outcome, analytics) = Box::pin(crate::run_with_retry( let (outcome, analytics) = Box::pin(crate::run_with_retry(
GuardianReviewSessionLimits { GuardianReviewSessionLimits {
@@ -1,25 +1,26 @@
//! Renders bounded planned-action JSON while preserving tool and call identity. //! Renders complete planned-action JSON or rejects it for synchronous review.
//! String truncation precedes omission of optional fields. //! Action arguments cannot be shortened to fit the asynchronous classifier budget.
use codex_extension_api::ToolName; use codex_extension_api::ToolName;
use codex_extension_api::ToolPayload; use codex_extension_api::ToolPayload;
use codex_protocol::protocol::TruncationPolicy; use codex_protocol::protocol::TruncationPolicy;
use serde_json::json; use serde_json::json;
use super::transcript::truncate_entry;
pub(super) struct GuardianAction { pub(super) struct GuardianAction {
pub(super) tool_name: ToolName, pub(super) tool_name: ToolName,
pub(super) payload: ToolPayload, pub(super) payload: ToolPayload,
} }
pub(super) struct RenderedAction { #[derive(Debug, thiserror::Error)]
pub(super) text: String, pub(super) enum ActionRenderError {
pub(super) original_bytes: usize, #[error("Guardian action exceeds the {max_tokens}-token review limit")]
TooLarge { max_tokens: usize },
#[error(transparent)]
Serialization(#[from] serde_json::Error),
} }
impl GuardianAction { impl GuardianAction {
pub(super) fn render(self, max_action_tokens: usize) -> serde_json::Result<RenderedAction> { pub(super) fn render(self, max_action_tokens: usize) -> Result<String, ActionRenderError> {
let arguments = match self.payload { let arguments = match self.payload {
ToolPayload::Function { arguments } => { ToolPayload::Function { arguments } => {
serde_json::from_str(&arguments).unwrap_or(serde_json::Value::String(arguments)) serde_json::from_str(&arguments).unwrap_or(serde_json::Value::String(arguments))
@@ -42,129 +43,16 @@ impl GuardianAction {
.for_each(serde_json::Value::sort_all_objects); .for_each(serde_json::Value::sort_all_objects);
let max_action_bytes = TruncationPolicy::Tokens(max_action_tokens).byte_budget(); let max_action_bytes = TruncationPolicy::Tokens(max_action_tokens).byte_budget();
let rendered = serde_json::to_string_pretty(&action)?; let rendered = serde_json::to_string_pretty(&action)?;
let original_bytes = rendered.len();
if rendered.len().saturating_add(1) <= max_action_bytes { if rendered.len().saturating_add(1) <= max_action_bytes {
return Ok(RenderedAction { return Ok(rendered);
text: rendered,
original_bytes,
});
} }
if let Some(rendered) = fit_action_to_budget(&action, max_action_bytes, max_action_tokens)? Err(ActionRenderError::TooLarge {
{ max_tokens: max_action_tokens,
return Ok(RenderedAction {
text: rendered,
original_bytes,
});
}
let mut omission_key = "_guardian_omitted_fields".to_owned();
while action.contains_key(&omission_key) {
omission_key.push('_');
}
let mut retained = serde_json::Map::new();
for key in ["tool", "call_id"] {
if let Some(value) = action.get(key) {
retained.insert(key.to_owned(), value.clone());
}
}
let mut omitted = action.len().saturating_sub(retained.len());
retained.insert(omission_key.clone(), json!(omitted));
let mut optional_fields = action
.iter()
.filter(|(key, _)| !matches!(key.as_str(), "tool" | "call_id"))
.collect::<Vec<_>>();
optional_fields.sort_by_key(|(key, _)| {
!matches!(
key.as_str(),
"arguments" | "cmd" | "command" | "input" | "patch" | "path" | "url"
)
});
for (key, value) in optional_fields {
let mut candidate = retained.clone();
candidate.insert(key.clone(), value.clone());
candidate.insert(omission_key.clone(), json!(omitted.saturating_sub(1)));
candidate.sort_keys();
let minimized = render_action_with_limit(&candidate, /*max_tokens*/ 0)?;
if minimized.len().saturating_add(1) <= max_action_bytes {
retained = candidate;
omitted = omitted.saturating_sub(1);
}
}
retained.sort_keys();
let rendered = fit_action_to_budget(&retained, max_action_bytes, max_action_tokens)?
.ok_or_else(|| {
serde_json::Error::io(std::io::Error::other(format!(
"Guardian action identity exceeds the {max_action_tokens}-token limit"
)))
})?;
Ok(RenderedAction {
text: rendered,
original_bytes,
}) })
} }
} }
fn fit_action_to_budget(
action: &serde_json::Map<String, serde_json::Value>,
max_action_bytes: usize,
max_action_tokens: usize,
) -> serde_json::Result<Option<String>> {
let mut low = 0usize;
let mut high = max_action_tokens.saturating_add(1);
let mut best = None;
while low < high {
let max_tokens = low + (high - low) / 2;
let rendered = render_action_with_limit(action, max_tokens)?;
if rendered.len().saturating_add(1) <= max_action_bytes {
best = Some(rendered);
low = max_tokens.saturating_add(1);
} else {
high = max_tokens;
}
}
Ok(best)
}
fn render_action_with_limit(
action: &serde_json::Map<String, serde_json::Value>,
max_tokens: usize,
) -> serde_json::Result<String> {
let mut truncated = action.clone();
for (key, value) in &mut truncated {
if !matches!(key.as_str(), "tool" | "call_id") {
truncate_action_value(value, max_tokens);
}
}
serde_json::to_string_pretty(&truncated)
}
fn truncate_action_value(value: &mut serde_json::Value, max_tokens: usize) {
match value {
serde_json::Value::String(text) => {
let truncated = truncate_entry(text, max_tokens);
if truncated.len() < text.len() {
*text = truncated;
}
}
serde_json::Value::Array(values) => {
for value in values {
truncate_action_value(value, max_tokens);
}
}
serde_json::Value::Object(values) => {
for value in values.values_mut() {
truncate_action_value(value, max_tokens);
}
}
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
}
}
#[cfg(test)] #[cfg(test)]
#[path = "action_tests.rs"] #[path = "action_tests.rs"]
mod tests; mod tests;
@@ -1,96 +1,37 @@
//! Tests for bounded action rendering and field retention. //! Complete action rendering preserves nested arguments and rejects overflow.
use super::*;
use anyhow::Result; use anyhow::Result;
use codex_extension_api::ToolName;
use codex_extension_api::ToolPayload;
use codex_protocol::protocol::TruncationPolicy;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::json; use serde_json::Value;
use super::GuardianAction;
use crate::async_scorer::config::DEFAULT_MODEL_CONTEXT_ITEM_TOKENS;
#[test] #[test]
fn guardian_action_bounds_structurally_oversized_arrays() -> Result<()> { fn guardian_action_preserves_complete_arguments_at_budget_boundary() -> Result<()> {
let action = GuardianAction { let arguments = json!({
tool_name: ToolName::plain("inspect_values"), "call_id": "genuine-call",
payload: ToolPayload::Function { "tool": "untrusted-tool",
arguments: json!({ "attachments": [{"content": "🦀\"\\\n".repeat(/*n*/ 40) + "required suffix"}],
"call_id": "genuine-call", "metadata": {"description": "required argument", "values": [1, false, null]},
"tool": "spoofed-tool", });
"values": (0..6_000).collect::<Vec<_>>(), let render = |max_action_tokens| {
}) GuardianAction {
.to_string(), tool_name: ToolName::plain("inspect_values"),
}, payload: ToolPayload::Function {
arguments: arguments.to_string(),
},
}
.render(max_action_tokens)
}; };
let mut expected = arguments.clone();
let rendered = action.render(DEFAULT_MODEL_CONTEXT_ITEM_TOKENS)?.text; expected["tool"] = json!("inspect_values");
assert!( let bytes = serde_json::to_string_pretty(&expected)?.len();
rendered.len().saturating_add(1) let budget = TruncationPolicy::Bytes(bytes.saturating_add(1)).token_budget();
<= TruncationPolicy::Tokens(DEFAULT_MODEL_CONTEXT_ITEM_TOKENS).byte_budget() let rendered = render(budget)?;
); assert_eq!(serde_json::from_str::<Value>(&rendered)?, expected);
let action = serde_json::from_str::<serde_json::Value>(&rendered)?; assert_eq!(rendered.len(), bytes);
assert_eq!( assert!(matches!(
action, render(budget - 1),
json!({ Err(ActionRenderError::TooLarge { .. })
"_guardian_omitted_fields": 1, ));
"call_id": "genuine-call",
"tool": "inspect_values",
})
);
Ok(())
}
#[test]
fn guardian_action_bounds_structurally_oversized_object_keys() -> Result<()> {
let oversized_key = "oversized_key_".to_owned()
+ &"k".repeat(TruncationPolicy::Tokens(DEFAULT_MODEL_CONTEXT_ITEM_TOKENS).byte_budget());
let mut arguments = serde_json::Map::from_iter([
(
"_guardian_omitted_fields".to_owned(),
json!("actual-tool-argument"),
),
("call_id".to_owned(), json!("genuine-call")),
("cmd".to_owned(), json!("remove-important-file")),
("tool".to_owned(), json!("spoofed-tool")),
(oversized_key.clone(), json!(true)),
]);
for index in 0..600 {
arguments.insert(format!("a_{index:04}_{}", "k".repeat(64)), json!(index));
}
let original_field_count = arguments.len();
let action = GuardianAction {
tool_name: ToolName::plain("inspect_fields"),
payload: ToolPayload::Function {
arguments: serde_json::Value::Object(arguments).to_string(),
},
};
let rendered = action.render(DEFAULT_MODEL_CONTEXT_ITEM_TOKENS)?.text;
assert!(
rendered.len().saturating_add(1)
<= TruncationPolicy::Tokens(DEFAULT_MODEL_CONTEXT_ITEM_TOKENS).byte_budget()
);
let action = serde_json::from_str::<serde_json::Value>(&rendered)?;
let fields = action
.as_object()
.expect("the bounded action must remain a JSON object");
assert_eq!(fields.get("tool"), Some(&json!("inspect_fields")));
assert_eq!(fields.get("call_id"), Some(&json!("genuine-call")));
assert_eq!(fields.get("cmd"), Some(&json!("remove-important-file")));
assert_eq!(
fields.get("_guardian_omitted_fields"),
Some(&json!("actual-tool-argument"))
);
assert!(fields.len() < original_field_count);
assert!(!fields.contains_key(&oversized_key));
assert!(
fields
.get("_guardian_omitted_fields_")
.and_then(serde_json::Value::as_u64)
.is_some_and(|omitted| omitted > 0)
);
Ok(()) Ok(())
} }
@@ -22,6 +22,7 @@ use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::openai_models::GuardianReviewMode; use codex_protocol::openai_models::GuardianReviewMode;
use codex_protocol::openai_models::GuardianScope; use codex_protocol::openai_models::GuardianScope;
use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::TruncationPolicy;
use codex_protocol::security_risk::SecurityRiskScore; use codex_protocol::security_risk::SecurityRiskScore;
use std::sync::Weak; use std::sync::Weak;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@@ -116,7 +117,10 @@ impl GuardianApprovalReviewer {
?reason, ?reason,
"reviewing approval" "reviewing approval"
); );
ApprovalDecision::Reviewed(input.synchronous_reviewer.review(reason).await) match input.synchronous_reviewer.review(reason).await {
Some(decision) => ApprovalDecision::Reviewed(decision),
None => ApprovalDecision::AskUser,
}
} }
} }
@@ -132,6 +136,30 @@ async fn cached_evidence(
record_fast_decision(metrics, "deferred", "missing_score"); record_fast_decision(metrics, "deferred", "missing_score");
return Err(GuardianReviewReason::MissingScore); return Err(GuardianReviewReason::MissingScore);
}; };
// Elicitations and intercepted execs can expand beyond the original scored action.
let mut action = input.action.clone();
if action.get("tool").and_then(serde_json::Value::as_str) == Some("mcp_tool_call")
&& let Some(fields) = action.as_object_mut()
{
// Match the action renderer: host descriptions are optional, arguments are not.
fields.remove("tool_description");
fields.remove("connector_description");
}
let max_action_bytes = TruncationPolicy::Tokens(config.max_action_tokens).byte_budget();
let action_fits = serde_json::to_string_pretty(&action)
.is_ok_and(|action| action.len().saturating_add(1) <= max_action_bytes);
if !action_fits
|| input.tool_call_id.is_some_and(|call_id| {
progress
.oversized_tool_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(call_id)
})
{
record_fast_decision(metrics, "deferred", "scoring_failure");
return Err(GuardianReviewReason::ScoringFailure);
}
let context_mode = store let context_mode = store
.get_or_init(GuardianReviewEvidence::default) .get_or_init(GuardianReviewEvidence::default)
.context_mode(); .context_mode();
@@ -1,6 +1,7 @@
use super::transcript::ContextInput; use super::transcript::ContextInput;
use codex_core::context::ContextualUserFragment; use codex_core::context::ContextualUserFragment;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::Weak; use std::sync::Weak;
@@ -28,6 +29,7 @@ use codex_extension_api::SkillInvocationContributor;
use codex_extension_api::SkillInvocationInput; use codex_extension_api::SkillInvocationInput;
use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput; use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolFinishInput;
use codex_extension_api::ToolLifecycleContributor; use codex_extension_api::ToolLifecycleContributor;
use codex_extension_api::ToolLifecycleFuture; use codex_extension_api::ToolLifecycleFuture;
use codex_extension_api::ToolStartInput; use codex_extension_api::ToolStartInput;
@@ -46,8 +48,8 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::has_full_access; use codex_protocol::protocol::has_full_access;
use codex_protocol::security_risk::SecurityRiskScore; use codex_protocol::security_risk::SecurityRiskScore;
use super::action::ActionRenderError;
use super::action::GuardianAction; use super::action::GuardianAction;
use super::action::RenderedAction;
use super::authorization::ScoreAuthorization; use super::authorization::ScoreAuthorization;
use super::config::GuardianV2Config; use super::config::GuardianV2Config;
use super::coverage::UnscoredAction; use super::coverage::UnscoredAction;
@@ -82,6 +84,9 @@ pub(super) struct GuardianV2ScoreProgress {
pub(super) js_executions: AtomicUsize, pub(super) js_executions: AtomicUsize,
pub(super) latest_scored_tool_call: AtomicUsize, pub(super) latest_scored_tool_call: AtomicUsize,
pub(super) latest_failed_tool_call: AtomicUsize, pub(super) latest_failed_tool_call: AtomicUsize,
// Keep overflow attached to each active call even after a newer score succeeds.
// The host's finish callback removes entries on completion, failure, or cancellation.
pub(super) oversized_tool_calls: Mutex<BTreeSet<String>>,
// Serialize successful score publication with its authorization metadata. // Serialize successful score publication with its authorization metadata.
pub(super) authorization: Mutex<Option<ScoreAuthorization>>, pub(super) authorization: Mutex<Option<ScoreAuthorization>>,
metrics: Option<Arc<dyn ExtensionMetrics>>, metrics: Option<Arc<dyn ExtensionMetrics>>,
@@ -210,6 +215,18 @@ impl ToolLifecycleContributor for GuardianV2Extension {
fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> { fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
Box::pin(self.score_tool(input)) Box::pin(self.score_tool(input))
} }
fn on_tool_finish<'a>(&'a self, input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> {
Box::pin(async move {
if let Some(progress) = input.thread_store.get::<GuardianV2ScoreProgress>() {
progress
.oversized_tool_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(input.call_id);
}
})
}
} }
impl GuardianV2Extension { impl GuardianV2Extension {
@@ -427,6 +444,45 @@ impl GuardianV2Extension {
tool_name: input.tool_name.clone(), tool_name: input.tool_name.clone(),
payload: input.payload.clone(), payload: input.payload.clone(),
}; };
let planned_action = match action.render(guardian_config.max_action_tokens) {
Ok(text) => text,
Err(ActionRenderError::TooLarge { .. }) => {
score_progress
.oversized_tool_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(input.call_id.to_owned());
score_progress
.latest_failed_tool_call
.fetch_max(tool_call_index, Ordering::Release);
Self::record_fail_closed_score(input.thread_store, sampled_at);
record_classification(
metrics.as_deref(),
classification_started_at.elapsed(),
"failure",
Some("input_too_large"),
);
return;
}
Err(error) => {
score_progress
.latest_failed_tool_call
.fetch_max(tool_call_index, Ordering::Release);
Self::record_fail_closed_score(input.thread_store, sampled_at);
record_classification(
metrics.as_deref(),
classification_started_at.elapsed(),
"failure",
Some("action_serialization_error"),
);
event_sink.emit_warning(ExtensionWarning {
thread_id,
turn_id: Some(turn_id),
message: format!("Guardian V2 action serialization failed: {error}"),
});
return;
}
};
let review_model_override = parent_model let review_model_override = parent_model
.as_ref() .as_ref()
.and_then(|model| model.auto_review_model_override.clone()); .and_then(|model| model.auto_review_model_override.clone());
@@ -489,30 +545,6 @@ impl GuardianV2Extension {
model: parent_model.clone(), model: parent_model.clone(),
..score_authorization ..score_authorization
}; };
let planned_action = match action.render(guardian_config.max_action_tokens) {
Ok(RenderedAction {
text,
original_bytes,
}) => {
truncations.record("action", original_bytes, text.len());
text
}
Err(error) => {
Self::record_fail_closed_score(thread.thread_extension_data(), sampled_at);
record_classification(
metrics.as_deref(),
classification_started_at.elapsed(),
"failure",
Some("action_serialization_error"),
);
event_sink.emit_warning(ExtensionWarning {
thread_id,
turn_id: Some(turn_id),
message: format!("Guardian V2 action serialization failed: {error}"),
});
return;
}
};
let action_section = PlannedAction { let action_section = PlannedAction {
json: planned_action.clone(), json: planned_action.clone(),
tool_descriptions: None, tool_descriptions: None,
@@ -71,7 +71,6 @@ use super::GuardianV2Extension;
use super::GuardianV2ScoreProgress; use super::GuardianV2ScoreProgress;
use crate::async_scorer::config::CLASSIFICATION_OUTPUT_INSTRUCTIONS; use crate::async_scorer::config::CLASSIFICATION_OUTPUT_INSTRUCTIONS;
use crate::async_scorer::config::DEFAULT_MODEL_CONTEXT_ITEM_TOKENS;
use crate::async_scorer::config::DEFAULT_PARENT_COMPACTION_TOKENS; use crate::async_scorer::config::DEFAULT_PARENT_COMPACTION_TOKENS;
use crate::async_scorer::config::GuardianV2Config; use crate::async_scorer::config::GuardianV2Config;
use crate::async_scorer::coverage::GuardianPolicy; use crate::async_scorer::coverage::GuardianPolicy;
@@ -90,8 +89,6 @@ use crate::async_scorer::sampler::tests::proxy_websocket_servers_with_http;
use crate::async_scorer::transcript::MAX_MESSAGE_ENTRY_TOKENS; use crate::async_scorer::transcript::MAX_MESSAGE_ENTRY_TOKENS;
use crate::async_scorer::transcript::MAX_TOOL_ENTRY_TOKENS; use crate::async_scorer::transcript::MAX_TOOL_ENTRY_TOKENS;
use crate::async_scorer::transcript::truncate_entry; use crate::async_scorer::transcript::truncate_entry;
use crate::async_scorer::truncation::CLASSIFICATION_TRUNCATION_BYTES_METRIC;
use crate::async_scorer::truncation::CLASSIFICATION_TRUNCATION_METRIC;
use codex_features::GuardianV2ReviewScopeConfigToml; use codex_features::GuardianV2ReviewScopeConfigToml;
const TEST_GUARDIAN_POLICY: &str = const TEST_GUARDIAN_POLICY: &str =
@@ -1204,19 +1201,6 @@ async fn contributor_fails_closed_when_model_configuration_is_invalid() -> Resul
fixture.assert_fails_closed("elevated_risk").await fixture.assert_fails_closed("elevated_risk").await
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn contributor_fails_closed_when_action_serialization_fails() -> Result<()> {
skip_if_no_network!(Ok(()));
let fixture = GuardianFailureFixture::new().await?;
let oversized_tool_name = ToolName::plain(
"x".repeat(TruncationPolicy::Tokens(DEFAULT_MODEL_CONTEXT_ITEM_TOKENS).byte_budget()),
);
fixture.score_tool(oversized_tool_name).await;
fixture.assert_fails_closed("elevated_risk").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn contributor_fails_closed_when_luna_classification_fails() -> Result<()> { async fn contributor_fails_closed_when_luna_classification_fails() -> Result<()> {
skip_if_no_network!(Ok(())); skip_if_no_network!(Ok(()));
@@ -1388,7 +1372,7 @@ max_recent_non_user_entries = 8
internal_chat_message_metadata_passthrough: None, internal_chat_message_metadata_passthrough: None,
}, },
]; ];
let arguments = json!({"body": "x".repeat(4_000)}).to_string(); let arguments = json!({"body": "x".repeat(/*n*/ 128)}).to_string();
let (request, test, registry) = sample_configured_conversation_history( let (request, test, registry) = sample_configured_conversation_history(
conversation_history, conversation_history,
&arguments, &arguments,
@@ -1433,16 +1417,11 @@ max_recent_non_user_entries = 8
.as_str() .as_str()
.expect("planned action should be a text item"); .expect("planned action should be a text item");
assert!(action.len() <= TruncationPolicy::Tokens(/*limit*/ 128).byte_budget()); assert!(action.len() <= TruncationPolicy::Tokens(/*limit*/ 128).byte_budget());
let original_action_bytes = i64::try_from(
serde_json::to_string_pretty(&json!({
"body": "x".repeat(4_000),
"tool": "read_file",
}))?
.len(),
)?;
let action: serde_json::Value = serde_json::from_str(action)?; let action: serde_json::Value = serde_json::from_str(action)?;
let retained_action_bytes = i64::try_from(serde_json::to_string_pretty(&action)?.len())?; assert_eq!(
assert_eq!(action["tool"], "read_file"); action,
json!({"body": "x".repeat(/*n*/ 128), "tool": "read_file"})
);
let thread_store = test.codex.thread_extension_data(); let thread_store = test.codex.thread_extension_data();
let score_progress = thread_store let score_progress = thread_store
@@ -1454,7 +1433,7 @@ max_recent_non_user_entries = 8
.latest_scored_tool_call .latest_scored_tool_call
.load(Ordering::Acquire) .load(Ordering::Acquire)
== 0 == 0
|| metrics.classification_samples().len() < 14 || metrics.classification_samples().len() < 10
{ {
tokio::task::yield_now().await; tokio::task::yield_now().await;
} }
@@ -1507,6 +1486,33 @@ max_recent_non_user_entries = 8
Some(ReviewDecision::Approved) Some(ReviewDecision::Approved)
); );
// A small scored call does not cover expanded elicitation arguments or an
// intercepted exec with no tool-call ID. Both must still fit the action budget.
for (argument, expected) in [
("small".to_owned(), Some(ReviewDecision::Approved)),
("expanded argument ".repeat(/*n*/ 128), None),
] {
for action in [
json!({
"tool": "mcp_tool_call", "server": "example", "id": "call-1",
"arguments": {"body": argument},
"tool_description": "optional metadata ".repeat(/*n*/ 128),
}),
json!({"tool": "exec_command", "program": "example", "argv": [argument]}),
] {
assert_eq!(
cached_approval(
&registry,
thread_store,
&action.to_string(),
/*metrics*/ None
)
.await,
expected
);
}
}
assert_eq!( assert_eq!(
score_progress score_progress
.latest_scored_tool_call .latest_scored_tool_call
@@ -1607,34 +1613,7 @@ max_recent_non_user_entries = 8
classification_duration_ms, classification_duration_ms,
vec![("outcome".to_owned(), "success".to_owned())], vec![("outcome".to_owned(), "success".to_owned())],
), ),
RecordedMetric::Counter(
CLASSIFICATION_TRUNCATION_METRIC.to_owned(),
1,
vec![
("component".to_owned(), "action".to_owned()),
("disposition".to_owned(), "truncated".to_owned()),
],
),
]) ])
.chain(
[
("original", original_action_bytes),
("retained", retained_action_bytes),
("omitted", original_action_bytes - retained_action_bytes),
]
.into_iter()
.map(|(measurement, bytes)| {
RecordedMetric::Histogram(
CLASSIFICATION_TRUNCATION_BYTES_METRIC.to_owned(),
bytes,
vec![
("component".to_owned(), "action".to_owned()),
("disposition".to_owned(), "truncated".to_owned()),
("measurement".to_owned(), measurement.to_owned()),
],
)
}),
)
.chain([ .chain([
RecordedMetric::Histogram(TOOL_CALL_LAG_METRIC.to_owned(), 0, vec![]), RecordedMetric::Histogram(TOOL_CALL_LAG_METRIC.to_owned(), 0, vec![]),
fast_decision_metric("deferred", "elevated_risk"), fast_decision_metric("deferred", "elevated_risk"),
@@ -1787,7 +1766,7 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu
internal_chat_message_metadata_passthrough: None, internal_chat_message_metadata_passthrough: None,
}, },
]; ];
let arguments = json!({"body": "x".repeat(4_000)}).to_string(); let arguments = json!({"body": "x".repeat(/*n*/ 128)}).to_string();
let local_config = "[features.guardianv2]\nenabled = true\nreview_threshold = 0.70\n"; let local_config = "[features.guardianv2]\nenabled = true\nreview_threshold = 0.70\n";
let (request, test, registry) = sample_configured_conversation_history( let (request, test, registry) = sample_configured_conversation_history(
conversation_history, conversation_history,
@@ -3212,78 +3191,13 @@ async fn legacy_contributor_can_disable_parent_compaction_reuse() -> Result<()>
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn contributor_bounds_oversized_actions_and_fairly_truncates_nested_fields() -> Result<()> {
skip_if_no_network!(Ok(()));
let arguments = json!({
"attachments": [{
"content": "🦀\"\\\n".repeat(20_000),
"name": "financials.csv",
}],
"call_id": "untrusted-call",
"metadata": { "reason": "b".repeat(100_000) },
"path": "a".repeat(100_000),
"recipient": "finance@example.com",
"tool": "untrusted-tool",
})
.to_string();
let (request, _test, _registry) =
sample_conversation_history(Vec::new(), &arguments, Some(TEST_GUARDIAN_POLICY)).await?;
let content = request["input"][2]["content"]
.as_array()
.expect("Luna user content should contain separate text items");
let action_text = content[content.len() - 2]["text"]
.as_str()
.expect("the current action should be an input text item");
let action = serde_json::from_str::<serde_json::Value>(action_text)?;
let max_action_bytes =
TruncationPolicy::Tokens(DEFAULT_MODEL_CONTEXT_ITEM_TOKENS).byte_budget();
assert!(action_text.ends_with('\n'));
assert!(
action_text.len() <= max_action_bytes,
"the complete model-visible action must remain bounded"
);
assert!(
action_text.len() >= max_action_bytes * 9 / 10,
"water-filling should use the available action budget"
);
assert_eq!(action["tool"], "read_file");
assert_eq!(action["call_id"], "untrusted-call");
assert_eq!(action["recipient"], "finance@example.com");
assert_eq!(action["attachments"][0]["name"], "financials.csv");
assert!(action.get("arguments_preview").is_none());
assert!(action.get("truncated").is_none());
let retained_values = [
&action["path"],
&action["metadata"]["reason"],
&action["attachments"][0]["content"],
]
.map(|value| {
value
.as_str()
.expect("action string field should remain present")
});
for text in retained_values {
assert!(text.contains("<truncated omitted_approx_tokens=\""));
}
let smallest_retained = retained_values.iter().map(|text| text.len()).min().unwrap();
let largest_retained = retained_values.iter().map(|text| text.len()).max().unwrap();
assert!(
largest_retained.saturating_sub(smallest_retained) <= 16,
"long nested strings should receive comparable shares of the action budget"
);
Ok(())
}
struct CacheMiss; struct CacheMiss;
impl codex_extension_api::SynchronousApprovalReviewer for CacheMiss { impl codex_extension_api::SynchronousApprovalReviewer for CacheMiss {
fn review( fn review(
&self, &self,
_reason: codex_protocol::approvals::GuardianReviewReason, _reason: codex_protocol::approvals::GuardianReviewReason,
) -> codex_extension_api::ExtensionFuture<'_, ReviewDecision> { ) -> codex_extension_api::ExtensionFuture<'_, Option<ReviewDecision>> {
Box::pin(async { ReviewDecision::denied("cache miss") }) Box::pin(async { Some(ReviewDecision::denied("cache miss")) })
} }
} }
+3 -3
View File
@@ -1,6 +1,6 @@
//! Planned-action prompt framing shared by the two production reviewers. //! Planned-action prompt framing shared by the two production reviewers.
//! Hosts retain their action serialization and truncation policies; this module //! Hosts serialize complete actions; whole-request admission bounds the input.
//! only renders already-bounded JSON and reasons, preserving content-item boundaries. //! This module frames JSON and reasons before composition splits long text for transport.
use crate::ContextSection; use crate::ContextSection;
use crate::SectionContributor; use crate::SectionContributor;
@@ -8,7 +8,7 @@ use crate::SectionError;
use crate::SectionInput; use crate::SectionInput;
use crate::SectionScope; use crate::SectionScope;
/// Host-prepared action evidence. JSON and optional reason must already be bounded. /// Host-prepared action evidence. JSON stays complete through request admission.
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub struct PlannedAction { pub struct PlannedAction {
pub json: String, pub json: String,
+15 -1
View File
@@ -168,7 +168,21 @@ pub fn estimate_input_tokens(item: &ResponseItem) -> usize {
} }
pub(super) fn content_tokens(item: &ContentItem) -> usize { pub(super) fn content_tokens(item: &ContentItem) -> usize {
let bytes = ByteCount::measure(|counter| serde_json::to_writer(counter, item)); let mut bytes = ByteCount::measure(|counter| serde_json::to_writer(counter, item));
if let ContentItem::InputText { text } = item {
let extra_parts = crate::composition::bounded_text_parts(text)
.count()
.saturating_sub(1);
let framing = ByteCount::measure(|counter| {
serde_json::to_writer(
counter,
&ContentItem::InputText {
text: String::new(),
},
)
});
bytes = bytes.saturating_add(extra_parts.saturating_mul(framing.saturating_add(1)));
}
adjusted_tokens(bytes, std::slice::from_ref(item)) adjusted_tokens(bytes, std::slice::from_ref(item))
} }
+35 -5
View File
@@ -2,10 +2,12 @@
//! Profiles retain the host-selected transcript slice; composition owns //! Profiles retain the host-selected transcript slice; composition owns
//! framing, message boundaries and section placement, without retaining history. //! framing, message boundaries and section placement, without retaining history.
//! Each content item keeps its selection policy until transport conversion. //! Each content item keeps its selection policy until transport conversion.
//! Long text splits losslessly only after admission, preserving whole-entry selection.
use codex_context_fragments::ContextualUserFragment; use codex_context_fragments::ContextualUserFragment;
use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem; use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::TruncationPolicy;
use codex_protocol::user_input::UserInput; use codex_protocol::user_input::UserInput;
use crate::ActionPresentation; use crate::ActionPresentation;
@@ -272,10 +274,13 @@ impl ComposedContext {
}; };
for item in content { for item in content {
inputs.push(match item.content { inputs.push(match item.content {
ContentItem::InputText { text } => UserInput::Text { ContentItem::InputText { text } => {
text, inputs.extend(bounded_text_parts(&text).map(|part| UserInput::Text {
text_elements: Vec::new(), text: part.to_owned(),
}, text_elements: Vec::new(),
}));
continue;
}
ContentItem::InputImage { image_url, detail } => { ContentItem::InputImage { image_url, detail } => {
UserInput::Image { image_url, detail } UserInput::Image { image_url, detail }
} }
@@ -297,7 +302,18 @@ impl ComposedContext {
for section in self.sections { for section in self.sections {
match section.delivery { match section.delivery {
SectionDelivery::UserContent(content) => { SectionDelivery::UserContent(content) => {
user_content.extend(content.into_iter().map(|item| item.content)) for item in content {
match item.content {
ContentItem::InputText { text } => {
user_content.extend(bounded_text_parts(&text).map(|part| {
ContentItem::InputText {
text: part.to_owned(),
}
}));
}
content => user_content.push(content),
}
}
} }
SectionDelivery::Message(message) => { SectionDelivery::Message(message) => {
if !user_content.is_empty() { if !user_content.is_empty() {
@@ -314,6 +330,20 @@ impl ComposedContext {
} }
} }
/// Bounds individual text parts without changing source text or entry identity.
/// Budget estimates include the extra wire framing before transport conversion.
pub(super) fn bounded_text_parts(text: &str) -> impl Iterator<Item = &str> {
let mut remaining = Some(text);
std::iter::from_fn(move || {
let text = remaining.take()?;
let end = text.floor_char_boundary(TruncationPolicy::Tokens(9_000).byte_budget());
if end < text.len() {
remaining = Some(&text[end..]);
}
Some(&text[..end])
})
}
pub(super) fn user_message(content: Vec<ContentItem>) -> ResponseItem { pub(super) fn user_message(content: Vec<ContentItem>) -> ResponseItem {
ResponseItem::Message { ResponseItem::Message {
id: None, id: None,
@@ -54,3 +54,44 @@ fn delivery_preserves_arbitrary_message_boundaries_and_rejects_them_for_sync() {
}) })
); );
} }
#[test]
fn long_text_delivery_is_lossless_bounded_and_fully_budgeted() {
let text = "é🙂\"\n".repeat(/*n*/ 20_000);
let context = ComposedContext {
sections: vec![SectionOutput {
id: "planned_action",
delivery: text_content(vec![text.clone()]),
}],
truncations: Vec::new(),
};
let estimated = context.estimated_tokens();
let messages = context.clone().into_messages();
let ResponseItem::Message { content, .. } = &messages[0] else {
panic!("user message")
};
let parts = content
.iter()
.map(|item| match item {
ContentItem::InputText { text } => text.as_str(),
_ => panic!("text part"),
})
.collect::<Vec<_>>();
assert_eq!(parts.concat(), text);
assert!(
parts
.iter()
.all(|part| part.len() <= TruncationPolicy::Tokens(9_000).byte_budget())
);
assert!(estimated >= crate::estimate_input_tokens(&messages[0]));
assert_eq!(
context.into_user_inputs().unwrap(),
parts
.into_iter()
.map(|part| UserInput::Text {
text: part.to_owned(),
text_elements: Vec::new(),
})
.collect::<Vec<_>>()
);
}