Skip to content

Commit ba839c2

Browse files
authored
changing decision semantics after guardian timeout (openai#17486)
**Summary** This PR treats Guardian timeouts as distinct from explicit denials in the core approval paths. Timeouts now return timeout-specific guidance instead of Guardian policy-rejection messaging. It updates the command, shell, network, and MCP approval flows and adds focused test coverage.
1 parent 1325bcd commit ba839c2

8 files changed

Lines changed: 96 additions & 16 deletions

File tree

codex-rs/core/src/guardian/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub(crate) use approval_request::GuardianApprovalRequest;
2626
pub(crate) use approval_request::GuardianMcpAnnotations;
2727
pub(crate) use approval_request::guardian_approval_request_to_json;
2828
pub(crate) use review::guardian_rejection_message;
29+
pub(crate) use review::guardian_timeout_message;
2930
pub(crate) use review::is_guardian_reviewer_source;
3031
pub(crate) use review::new_guardian_review_id;
3132
pub(crate) use review::review_approval_request;

codex-rs/core/src/guardian/review.rs

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ const GUARDIAN_REJECTION_INSTRUCTIONS: &str = concat!(
3838
"Otherwise, stop and request user input.",
3939
);
4040

41+
const GUARDIAN_TIMEOUT_INSTRUCTIONS: &str = concat!(
42+
"The automatic approval review did not finish before its deadline. ",
43+
"Do not assume the action is unsafe based on the timeout alone. ",
44+
"You may retry once with a narrower or simpler request, or ask the user for guidance or explicit approval.",
45+
);
46+
4147
pub(crate) fn new_guardian_review_id() -> String {
4248
uuid::Uuid::new_v4().to_string()
4349
}
@@ -63,6 +69,10 @@ pub(crate) async fn guardian_rejection_message(session: &Session, review_id: &st
6369
}
6470
}
6571

72+
pub(crate) fn guardian_timeout_message() -> String {
73+
GUARDIAN_TIMEOUT_INSTRUCTIONS.to_string()
74+
}
75+
6676
#[derive(Debug)]
6777
pub(super) enum GuardianReviewOutcome {
6878
Completed(anyhow::Result<GuardianAssessment>),
@@ -97,8 +107,9 @@ pub(crate) fn is_guardian_reviewer_source(
97107
)
98108
}
99109

100-
/// This function always fails closed: any timeout, review-session failure, or
101-
/// parse failure is treated as a high-risk denial.
110+
/// This function always fails closed: timeouts, review-session failures, and
111+
/// parse failures all block execution, but timeouts are still surfaced to the
112+
/// caller as distinct from explicit guardian denials.
102113
async fn run_guardian_review(
103114
session: Arc<Session>,
104115
turn: Arc<TurnContext>,
@@ -170,14 +181,36 @@ async fn run_guardian_review(
170181
outcome: GuardianAssessmentOutcome::Deny,
171182
rationale: format!("Automatic approval review failed: {err}"),
172183
},
173-
GuardianReviewOutcome::TimedOut => GuardianAssessment {
174-
risk_level: GuardianRiskLevel::High,
175-
user_authorization: GuardianUserAuthorization::Unknown,
176-
outcome: GuardianAssessmentOutcome::Deny,
177-
rationale:
184+
GuardianReviewOutcome::TimedOut => {
185+
let rationale =
178186
"Automatic approval review timed out while evaluating the requested approval."
179-
.to_string(),
180-
},
187+
.to_string();
188+
session
189+
.send_event(
190+
turn.as_ref(),
191+
EventMsg::Warning(WarningEvent {
192+
message: rationale.clone(),
193+
}),
194+
)
195+
.await;
196+
session
197+
.send_event(
198+
turn.as_ref(),
199+
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
200+
id: review_id,
201+
target_item_id,
202+
turn_id: assessment_turn_id,
203+
status: GuardianAssessmentStatus::TimedOut,
204+
risk_level: None,
205+
user_authorization: None,
206+
rationale: Some(rationale),
207+
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
208+
action: terminal_action,
209+
}),
210+
)
211+
.await;
212+
return ReviewDecision::TimedOut;
213+
}
181214
GuardianReviewOutcome::Aborted => {
182215
session
183216
.send_event(

codex-rs/core/src/guardian/tests.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,14 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
718718
assert!(warnings.is_empty());
719719
}
720720

721+
#[test]
722+
fn guardian_timeout_message_distinguishes_timeout_from_policy_denial() {
723+
let message = guardian_timeout_message();
724+
assert!(message.contains("did not finish before its deadline"));
725+
assert!(message.contains("retry once"));
726+
assert!(!message.contains("unacceptable risk"));
727+
}
728+
721729
#[tokio::test]
722730
async fn routes_approval_to_guardian_requires_auto_only_review_policy() {
723731
let (_session, mut turn) = crate::codex::make_session_and_context().await;

codex-rs/core/src/mcp_tool_call.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::guardian::GuardianApprovalRequest;
2424
use crate::guardian::GuardianMcpAnnotations;
2525
use crate::guardian::guardian_approval_request_to_json;
2626
use crate::guardian::guardian_rejection_message;
27+
use crate::guardian::guardian_timeout_message;
2728
use crate::guardian::new_guardian_review_id;
2829
use crate::guardian::review_approval_request;
2930
use crate::guardian::routes_approval_to_guardian;
@@ -980,9 +981,12 @@ async fn mcp_tool_approval_decision_from_guardian(
980981
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
981982
| ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept,
982983
ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession,
983-
ReviewDecision::Denied | ReviewDecision::TimedOut => McpToolApprovalDecision::Decline {
984+
ReviewDecision::Denied => McpToolApprovalDecision::Decline {
984985
message: Some(guardian_rejection_message(sess, review_id).await),
985986
},
987+
ReviewDecision::TimedOut => McpToolApprovalDecision::Decline {
988+
message: Some(guardian_timeout_message()),
989+
},
986990
ReviewDecision::Abort => McpToolApprovalDecision::Decline { message: None },
987991
}
988992
}

codex-rs/core/src/mcp_tool_call_tests.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,20 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() {
843843
};
844844
assert!(message.contains("Reason: too risky"));
845845
assert!(message.contains("The agent must not attempt to achieve the same outcome"));
846+
let timeout = mcp_tool_approval_decision_from_guardian(
847+
session.as_ref(),
848+
"review-id",
849+
ReviewDecision::TimedOut,
850+
)
851+
.await;
852+
let McpToolApprovalDecision::Decline {
853+
message: Some(message),
854+
} = timeout
855+
else {
856+
panic!("guardian timeout should carry a timeout message");
857+
};
858+
assert!(message.contains("did not finish before its deadline"));
859+
assert!(!message.contains("unacceptable risk"));
846860
assert_eq!(
847861
mcp_tool_approval_decision_from_guardian(
848862
session.as_ref(),

codex-rs/core/src/tools/network_approval.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::codex::Session;
22
use crate::guardian::GuardianApprovalRequest;
33
use crate::guardian::guardian_rejection_message;
4+
use crate::guardian::guardian_timeout_message;
45
use crate::guardian::new_guardian_review_id;
56
use crate::guardian::review_approval_request;
67
use crate::guardian::routes_approval_to_guardian;
@@ -488,7 +489,7 @@ impl NetworkApprovalService {
488489
PendingApprovalDecision::Deny
489490
}
490491
},
491-
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => {
492+
ReviewDecision::Denied | ReviewDecision::Abort => {
492493
if let Some(review_id) = guardian_review_id.as_deref() {
493494
if let Some(owner_call) = owner_call.as_ref() {
494495
let message = guardian_rejection_message(session.as_ref(), review_id).await;
@@ -507,6 +508,16 @@ impl NetworkApprovalService {
507508
}
508509
PendingApprovalDecision::Deny
509510
}
511+
ReviewDecision::TimedOut => {
512+
if let Some(owner_call) = owner_call.as_ref() {
513+
self.record_call_outcome(
514+
&owner_call.registration_id,
515+
NetworkApprovalOutcome::DeniedByPolicy(guardian_timeout_message()),
516+
)
517+
.await;
518+
}
519+
PendingApprovalDecision::Deny
520+
}
510521
};
511522

512523
if matches!(resolved, PendingApprovalDecision::AllowForSession) {

codex-rs/core/src/tools/orchestrator.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ retry with an escalated sandbox strategy on denial (no re‑approval thanks to
77
caching).
88
*/
99
use crate::guardian::guardian_rejection_message;
10+
use crate::guardian::guardian_timeout_message;
1011
use crate::guardian::new_guardian_review_id;
1112
use crate::guardian::routes_approval_to_guardian;
1213
use crate::network_policy_decision::network_approval_context_from_payload;
@@ -151,14 +152,17 @@ impl ToolOrchestrator {
151152
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
152153

153154
match decision {
154-
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => {
155+
ReviewDecision::Denied | ReviewDecision::Abort => {
155156
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
156157
guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await
157158
} else {
158159
"rejected by user".to_string()
159160
};
160161
return Err(ToolError::Rejected(reason));
161162
}
163+
ReviewDecision::TimedOut => {
164+
return Err(ToolError::Rejected(guardian_timeout_message()));
165+
}
162166
ReviewDecision::Approved
163167
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
164168
| ReviewDecision::ApprovedForSession => {}
@@ -306,9 +310,7 @@ impl ToolOrchestrator {
306310
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
307311

308312
match decision {
309-
ReviewDecision::Denied
310-
| ReviewDecision::TimedOut
311-
| ReviewDecision::Abort => {
313+
ReviewDecision::Denied | ReviewDecision::Abort => {
312314
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
313315
guardian_rejection_message(tool_ctx.session.as_ref(), review_id)
314316
.await
@@ -317,6 +319,9 @@ impl ToolOrchestrator {
317319
};
318320
return Err(ToolError::Rejected(reason));
319321
}
322+
ReviewDecision::TimedOut => {
323+
return Err(ToolError::Rejected(guardian_timeout_message()));
324+
}
320325
ReviewDecision::Approved
321326
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
322327
| ReviewDecision::ApprovedForSession => {}

codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::exec::ExecExpiration;
44
use crate::exec::is_likely_sandbox_denied;
55
use crate::guardian::GuardianApprovalRequest;
66
use crate::guardian::guardian_rejection_message;
7+
use crate::guardian::guardian_timeout_message;
78
use crate::guardian::new_guardian_review_id;
89
use crate::guardian::review_approval_request;
910
use crate::guardian::routes_approval_to_guardian;
@@ -485,7 +486,7 @@ impl CoreShellActionProvider {
485486
EscalationDecision::deny(Some("User denied execution".to_string()))
486487
}
487488
},
488-
ReviewDecision::Denied | ReviewDecision::TimedOut => {
489+
ReviewDecision::Denied => {
489490
let message = if let Some(review_id) =
490491
prompt_decision.guardian_review_id.as_deref()
491492
{
@@ -495,6 +496,9 @@ impl CoreShellActionProvider {
495496
};
496497
EscalationDecision::deny(Some(message))
497498
}
499+
ReviewDecision::TimedOut => {
500+
EscalationDecision::deny(Some(guardian_timeout_message()))
501+
}
498502
ReviewDecision::Abort => {
499503
EscalationDecision::deny(Some("User cancelled execution".to_string()))
500504
}

0 commit comments

Comments
 (0)