Compare commits

...

1 Commits

Author SHA1 Message Date
Owen Lin
e59f7fbf69 Add guardian review client overrides 2026-04-10 19:03:35 -07:00
24 changed files with 817 additions and 13 deletions

View File

@@ -987,6 +987,14 @@
],
"type": "object"
},
"GuardianApprovalReviewOverrideDecision": {
"description": "[UNSTABLE] Client decision that preempts a pending guardian approval review.",
"enum": [
"approve",
"decline"
],
"type": "string"
},
"ImageDetail": {
"enum": [
"auto",
@@ -1038,6 +1046,30 @@
],
"type": "object"
},
"ItemGuardianApprovalReviewOverrideParams": {
"description": "[UNSTABLE] Params for preempting a pending guardian approval review.",
"properties": {
"decision": {
"$ref": "#/definitions/GuardianApprovalReviewOverrideDecision"
},
"reviewId": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"decision",
"reviewId",
"threadId",
"turnId"
],
"type": "object"
},
"ListMcpServerStatusParams": {
"properties": {
"cursor": {
@@ -4402,6 +4434,30 @@
"title": "Review/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"item/autoApprovalReview/override"
],
"title": "Item/autoApprovalReview/overrideRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ItemGuardianApprovalReviewOverrideParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Item/autoApprovalReview/overrideRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -391,7 +391,8 @@
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
"agent"
"agent",
"user"
],
"type": "string"
},

View File

@@ -1058,6 +1058,30 @@
"title": "Review/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"item/autoApprovalReview/override"
],
"title": "Item/autoApprovalReview/overrideRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ItemGuardianApprovalReviewOverrideParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Item/autoApprovalReview/overrideRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -5539,7 +5563,8 @@
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
"agent"
"agent",
"user"
],
"type": "string"
},
@@ -8291,6 +8316,14 @@
}
]
},
"GuardianApprovalReviewOverrideDecision": {
"description": "[UNSTABLE] Client decision that preempts a pending guardian approval review.",
"enum": [
"approve",
"decline"
],
"type": "string"
},
"GuardianApprovalReviewStatus": {
"description": "[UNSTABLE] Lifecycle state for a guardian approval review.",
"enum": [
@@ -8619,6 +8652,38 @@
"title": "ItemGuardianApprovalReviewCompletedNotification",
"type": "object"
},
"ItemGuardianApprovalReviewOverrideParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Params for preempting a pending guardian approval review.",
"properties": {
"decision": {
"$ref": "#/definitions/v2/GuardianApprovalReviewOverrideDecision"
},
"reviewId": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"decision",
"reviewId",
"threadId",
"turnId"
],
"title": "ItemGuardianApprovalReviewOverrideParams",
"type": "object"
},
"ItemGuardianApprovalReviewOverrideResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Empty response for `item/autoApprovalReview/override`.",
"title": "ItemGuardianApprovalReviewOverrideResponse",
"type": "object"
},
"ItemGuardianApprovalReviewStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",

View File

@@ -718,7 +718,8 @@
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
"agent"
"agent",
"user"
],
"type": "string"
},
@@ -1640,6 +1641,30 @@
"title": "Review/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"item/autoApprovalReview/override"
],
"title": "Item/autoApprovalReview/overrideRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ItemGuardianApprovalReviewOverrideParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Item/autoApprovalReview/overrideRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -5050,6 +5075,14 @@
}
]
},
"GuardianApprovalReviewOverrideDecision": {
"description": "[UNSTABLE] Client decision that preempts a pending guardian approval review.",
"enum": [
"approve",
"decline"
],
"type": "string"
},
"GuardianApprovalReviewStatus": {
"description": "[UNSTABLE] Lifecycle state for a guardian approval review.",
"enum": [
@@ -5422,6 +5455,38 @@
"title": "ItemGuardianApprovalReviewCompletedNotification",
"type": "object"
},
"ItemGuardianApprovalReviewOverrideParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Params for preempting a pending guardian approval review.",
"properties": {
"decision": {
"$ref": "#/definitions/GuardianApprovalReviewOverrideDecision"
},
"reviewId": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"decision",
"reviewId",
"threadId",
"turnId"
],
"title": "ItemGuardianApprovalReviewOverrideParams",
"type": "object"
},
"ItemGuardianApprovalReviewOverrideResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Empty response for `item/autoApprovalReview/override`.",
"title": "ItemGuardianApprovalReviewOverrideResponse",
"type": "object"
},
"ItemGuardianApprovalReviewStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",

View File

@@ -4,7 +4,8 @@
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
"agent"
"agent",
"user"
],
"type": "string"
},

View File

@@ -0,0 +1,36 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"GuardianApprovalReviewOverrideDecision": {
"description": "[UNSTABLE] Client decision that preempts a pending guardian approval review.",
"enum": [
"approve",
"decline"
],
"type": "string"
}
},
"description": "[UNSTABLE] Params for preempting a pending guardian approval review.",
"properties": {
"decision": {
"$ref": "#/definitions/GuardianApprovalReviewOverrideDecision"
},
"reviewId": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"decision",
"reviewId",
"threadId",
"turnId"
],
"title": "ItemGuardianApprovalReviewOverrideParams",
"type": "object"
}

View File

@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Empty response for `item/autoApprovalReview/override`.",
"title": "ItemGuardianApprovalReviewOverrideResponse",
"type": "object"
}

File diff suppressed because one or more lines are too long

View File

@@ -5,4 +5,4 @@
/**
* [UNSTABLE] Source that produced a terminal guardian approval review decision.
*/
export type AutoReviewDecisionSource = "agent";
export type AutoReviewDecisionSource = "agent" | "user";

View File

@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* [UNSTABLE] Client decision that preempts a pending guardian approval review.
*/
export type GuardianApprovalReviewOverrideDecision = "approve" | "decline";

View File

@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { GuardianApprovalReviewOverrideDecision } from "./GuardianApprovalReviewOverrideDecision";
/**
* [UNSTABLE] Params for preempting a pending guardian approval review.
*/
export type ItemGuardianApprovalReviewOverrideParams = { threadId: string, turnId: string, reviewId: string, decision: GuardianApprovalReviewOverrideDecision, };

View File

@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* [UNSTABLE] Empty response for `item/autoApprovalReview/override`.
*/
export type ItemGuardianApprovalReviewOverrideResponse = Record<string, never>;

View File

@@ -125,6 +125,7 @@ export type { GitInfo } from "./GitInfo";
export type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
export type { GuardianApprovalReview } from "./GuardianApprovalReview";
export type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction";
export type { GuardianApprovalReviewOverrideDecision } from "./GuardianApprovalReviewOverrideDecision";
export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus";
export type { GuardianCommandSource } from "./GuardianCommandSource";
export type { GuardianRiskLevel } from "./GuardianRiskLevel";
@@ -142,6 +143,8 @@ export type { HookScope } from "./HookScope";
export type { HookStartedNotification } from "./HookStartedNotification";
export type { ItemCompletedNotification } from "./ItemCompletedNotification";
export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification";
export type { ItemGuardianApprovalReviewOverrideParams } from "./ItemGuardianApprovalReviewOverrideParams";
export type { ItemGuardianApprovalReviewOverrideResponse } from "./ItemGuardianApprovalReviewOverrideResponse";
export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification";
export type { ItemStartedNotification } from "./ItemStartedNotification";
export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams";

View File

@@ -424,6 +424,10 @@ client_request_definitions! {
params: v2::ReviewStartParams,
response: v2::ReviewStartResponse,
},
ItemGuardianApprovalReviewOverride => "item/autoApprovalReview/override" {
params: v2::ItemGuardianApprovalReviewOverrideParams,
response: v2::ItemGuardianApprovalReviewOverrideResponse,
},
ModelList => "model/list" {
params: v2::ModelListParams,

View File

@@ -54,6 +54,7 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource;
use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus;
use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig;
use codex_protocol::protocol::GuardianReviewOverrideDecision as CoreGuardianReviewOverrideDecision;
use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel;
use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization;
use codex_protocol::protocol::HookEventName as CoreHookEventName;
@@ -4541,12 +4542,33 @@ pub enum GuardianApprovalReviewStatus {
/// [UNSTABLE] Source that produced a terminal guardian approval review decision.
pub enum AutoReviewDecisionSource {
Agent,
#[serde(alias = "clientOverride")]
User,
}
impl From<CoreGuardianAssessmentDecisionSource> for AutoReviewDecisionSource {
fn from(value: CoreGuardianAssessmentDecisionSource) -> Self {
match value {
CoreGuardianAssessmentDecisionSource::Agent => Self::Agent,
CoreGuardianAssessmentDecisionSource::User => Self::User,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
/// [UNSTABLE] Client decision that preempts a pending guardian approval review.
pub enum GuardianApprovalReviewOverrideDecision {
Approve,
Decline,
}
impl From<GuardianApprovalReviewOverrideDecision> for CoreGuardianReviewOverrideDecision {
fn from(value: GuardianApprovalReviewOverrideDecision) -> Self {
match value {
GuardianApprovalReviewOverrideDecision::Approve => Self::Approve,
GuardianApprovalReviewOverrideDecision::Decline => Self::Decline,
}
}
}
@@ -4608,6 +4630,23 @@ pub struct GuardianApprovalReview {
pub rationale: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// [UNSTABLE] Params for preempting a pending guardian approval review.
pub struct ItemGuardianApprovalReviewOverrideParams {
pub thread_id: String,
pub turn_id: String,
pub review_id: String,
pub decision: GuardianApprovalReviewOverrideDecision,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// [UNSTABLE] Empty response for `item/autoApprovalReview/override`.
pub struct ItemGuardianApprovalReviewOverrideResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]

View File

@@ -69,6 +69,8 @@ use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::ItemGuardianApprovalReviewOverrideParams;
use codex_app_server_protocol::ItemGuardianApprovalReviewOverrideResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -854,6 +856,13 @@ impl CodexMessageProcessor {
self.review_start(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ItemGuardianApprovalReviewOverride { request_id, params } => {
self.item_guardian_approval_review_override(
to_connection_request_id(request_id),
params,
)
.await;
}
ClientRequest::GetConversationSummary { request_id, params } => {
self.get_thread_summary(to_connection_request_id(request_id), params)
.await;
@@ -5350,6 +5359,46 @@ impl CodexMessageProcessor {
});
}
async fn item_guardian_approval_review_override(
&self,
request_id: ConnectionRequestId,
params: ItemGuardianApprovalReviewOverrideParams,
) {
let (_, thread) = match self.load_thread(&params.thread_id).await {
Ok(thread) => thread,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
match thread
.override_guardian_review(&params.review_id, &params.turn_id, params.decision.into())
.await
{
Ok(()) => {
self.outgoing
.send_response(request_id, ItemGuardianApprovalReviewOverrideResponse {})
.await;
}
Err(codex_protocol::error::CodexErr::InvalidRequest(message)) => {
self.send_invalid_request_error(request_id, message).await;
}
Err(err) => {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
},
)
.await;
}
}
}
async fn send_invalid_request_error(&self, request_id: ConnectionRequestId, message: String) {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,

View File

@@ -3,6 +3,7 @@ use crate::codex::Codex;
use crate::codex::SteerInputError;
use crate::config::ConstraintResult;
use crate::file_watcher::WatchRegistration;
use crate::guardian::GuardianReviewOverrideError;
use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::Personality;
@@ -15,6 +16,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::GuardianReviewOverrideDecision;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
@@ -262,6 +264,29 @@ impl CodexThread {
Ok(*guard)
}
pub async fn override_guardian_review(
&self,
review_id: &str,
turn_id: &str,
decision: GuardianReviewOverrideDecision,
) -> CodexResult<()> {
self.codex
.session
.guardian_review_session
.override_review(review_id, turn_id, decision)
.await
.map_err(|err| match err {
GuardianReviewOverrideError::NotFound => CodexErr::InvalidRequest(format!(
"guardian review not found or already completed: {review_id}"
)),
GuardianReviewOverrideError::TurnMismatch { expected_turn_id } => {
CodexErr::InvalidRequest(format!(
"guardian review {review_id} belongs to turn {expected_turn_id}, not {turn_id}"
))
}
})
}
}
fn pending_message_input_item(message: &ResponseItem) -> CodexResult<ResponseInputItem> {

View File

@@ -31,6 +31,7 @@ pub(crate) use review::new_guardian_review_id;
pub(crate) use review::review_approval_request;
pub(crate) use review::review_approval_request_with_cancel;
pub(crate) use review::routes_approval_to_guardian;
pub(crate) use review_session::GuardianReviewOverrideError;
pub(crate) use review_session::GuardianReviewSessionManager;
const GUARDIAN_PREFERRED_MODEL: &str = "gpt-5.4";

View File

@@ -6,12 +6,15 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::GuardianReviewOverrideDecision;
use codex_protocol::protocol::GuardianRiskLevel;
use codex_protocol::protocol::GuardianUserAuthorization;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::WarningEvent;
use tokio::task::JoinError;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::codex::Session;
use crate::codex::TurnContext;
@@ -60,6 +63,11 @@ pub(crate) async fn guardian_rejection_message(session: &Session, review_id: &st
rejection.rationale.trim(),
GUARDIAN_REJECTION_INSTRUCTIONS
),
GuardianAssessmentDecisionSource::User => format!(
"This action was declined before the automatic approval review completed.\nReason: {}\n{}",
rejection.rationale.trim(),
GUARDIAN_REJECTION_INSTRUCTIONS
),
}
}
@@ -70,6 +78,22 @@ pub(super) enum GuardianReviewOutcome {
Aborted,
}
enum GuardianReviewSelection {
Review(GuardianReviewOutcome),
Override(GuardianReviewOverrideDecision),
OverrideClosed,
ExternalCancel,
}
struct GuardianReviewOverrideCompletion {
session: Arc<Session>,
turn: Arc<TurnContext>,
review_id: String,
target_item_id: Option<String>,
assessment_turn_id: String,
action_summary: codex_protocol::protocol::GuardianAssessmentAction,
}
fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str {
match level {
GuardianRiskLevel::Low => "low",
@@ -110,6 +134,10 @@ async fn run_guardian_review(
let target_item_id = guardian_request_target_item_id(&request).map(str::to_string);
let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string();
let action_summary = guardian_assessment_action(&request);
let override_rx = session
.guardian_review_session
.register_pending_override(review_id.clone(), assessment_turn_id.clone())
.await;
session
.send_event(
turn.as_ref(),
@@ -131,6 +159,10 @@ async fn run_guardian_review(
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
session
.guardian_review_session
.clear_pending_override(&review_id)
.await;
session
.send_event(
turn.as_ref(),
@@ -152,15 +184,90 @@ async fn run_guardian_review(
let schema = guardian_output_schema();
let terminal_action = action_summary.clone();
let outcome = run_guardian_review_session(
let review_cancel = CancellationToken::new();
let mut review_task = tokio::spawn(run_guardian_review_session(
session.clone(),
turn.clone(),
request,
retry_reason,
schema,
external_cancel,
)
.await;
Some(review_cancel.clone()),
));
let external_cancelled = async {
if let Some(cancel_token) = external_cancel.as_ref() {
cancel_token.cancelled().await;
} else {
std::future::pending::<()>().await;
}
};
tokio::pin!(external_cancelled);
let selection = tokio::select! {
result = &mut review_task => {
GuardianReviewSelection::Review(guardian_review_task_result(result))
}
override_decision = override_rx => {
match override_decision {
Ok(decision) => GuardianReviewSelection::Override(decision),
Err(_) => GuardianReviewSelection::OverrideClosed,
}
}
_ = &mut external_cancelled => GuardianReviewSelection::ExternalCancel,
};
let outcome = match selection {
GuardianReviewSelection::Review(outcome) => {
session
.guardian_review_session
.clear_pending_override(&review_id)
.await;
outcome
}
GuardianReviewSelection::Override(decision) => {
review_cancel.cancel();
if let Err(err) = review_task.await {
warn!("guardian review task failed after client override: {err}");
}
session
.guardian_review_session
.clear_pending_override(&review_id)
.await;
return finish_guardian_review_override(
GuardianReviewOverrideCompletion {
session,
turn,
review_id,
target_item_id,
assessment_turn_id,
action_summary,
},
decision,
)
.await;
}
GuardianReviewSelection::OverrideClosed => {
warn!("pending guardian review override channel closed before decision");
let outcome = match review_task.await {
Ok(outcome) => outcome,
Err(err) => guardian_review_task_result(Err(err)),
};
session
.guardian_review_session
.clear_pending_override(&review_id)
.await;
outcome
}
GuardianReviewSelection::ExternalCancel => {
review_cancel.cancel();
if let Err(err) = review_task.await {
warn!("guardian review task failed after external cancellation: {err}");
}
session
.guardian_review_session
.clear_pending_override(&review_id)
.await;
GuardianReviewOutcome::Aborted
}
};
let assessment = match outcome {
GuardianReviewOutcome::Completed(Ok(assessment)) => assessment,
@@ -262,6 +369,75 @@ async fn run_guardian_review(
}
}
fn guardian_review_task_result(
result: Result<GuardianReviewOutcome, JoinError>,
) -> GuardianReviewOutcome {
match result {
Ok(outcome) => outcome,
Err(err) => GuardianReviewOutcome::Completed(Err(anyhow::anyhow!(
"guardian review task failed: {err}"
))),
}
}
async fn finish_guardian_review_override(
completion: GuardianReviewOverrideCompletion,
decision: GuardianReviewOverrideDecision,
) -> ReviewDecision {
let GuardianReviewOverrideCompletion {
session,
turn,
review_id,
target_item_id,
assessment_turn_id,
action_summary,
} = completion;
let (status, review_decision, rationale) = match decision {
GuardianReviewOverrideDecision::Approve => (
GuardianAssessmentStatus::Approved,
ReviewDecision::Approved,
"User preempted the automatic guardian review and approved this review.".to_string(),
),
GuardianReviewOverrideDecision::Decline => (
GuardianAssessmentStatus::Denied,
ReviewDecision::Denied,
"User preempted the automatic guardian review and declined this review.".to_string(),
),
};
{
let mut rationales = session.services.guardian_rejections.lock().await;
if matches!(decision, GuardianReviewOverrideDecision::Approve) {
rationales.remove(&review_id);
} else {
let rejection = GuardianRejection {
rationale: rationale.clone(),
source: GuardianAssessmentDecisionSource::User,
};
rationales.insert(review_id.clone(), rejection);
}
}
session
.send_event(
turn.as_ref(),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: review_id,
target_item_id,
turn_id: assessment_turn_id,
status,
risk_level: None,
user_authorization: None,
rationale: Some(rationale),
decision_source: Some(GuardianAssessmentDecisionSource::User),
action: action_summary,
}),
)
.await;
review_decision
}
/// Public entrypoint for approval requests that should be reviewed by guardian.
pub(crate) async fn review_approval_request(
session: &Arc<Session>,

View File

@@ -12,6 +12,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianReviewOverrideDecision;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
@@ -19,6 +20,7 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SubAgentSource;
use serde_json::Value;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use tracing::warn;
@@ -84,6 +86,18 @@ pub(crate) struct GuardianReviewSessionManager {
struct GuardianReviewSessionState {
trunk: Option<Arc<GuardianReviewSession>>,
ephemeral_reviews: Vec<Arc<GuardianReviewSession>>,
pending_overrides: HashMap<String, PendingGuardianReviewOverride>,
}
struct PendingGuardianReviewOverride {
turn_id: String,
sender: oneshot::Sender<GuardianReviewOverrideDecision>,
}
#[derive(Debug)]
pub(crate) enum GuardianReviewOverrideError {
NotFound,
TurnMismatch { expected_turn_id: String },
}
struct GuardianReviewSession {
@@ -247,6 +261,53 @@ impl Drop for EphemeralReviewCleanup {
}
impl GuardianReviewSessionManager {
pub(crate) async fn register_pending_override(
&self,
review_id: String,
turn_id: String,
) -> oneshot::Receiver<GuardianReviewOverrideDecision> {
let (sender, receiver) = oneshot::channel();
let mut state = self.state.lock().await;
if state
.pending_overrides
.insert(
review_id.clone(),
PendingGuardianReviewOverride { turn_id, sender },
)
.is_some()
{
warn!("replaced pending guardian review override for review {review_id}");
}
receiver
}
pub(crate) async fn clear_pending_override(&self, review_id: &str) {
self.state.lock().await.pending_overrides.remove(review_id);
}
pub(crate) async fn override_review(
&self,
review_id: &str,
turn_id: &str,
decision: GuardianReviewOverrideDecision,
) -> Result<(), GuardianReviewOverrideError> {
let mut state = self.state.lock().await;
let Some(pending) = state.pending_overrides.remove(review_id) else {
return Err(GuardianReviewOverrideError::NotFound);
};
if pending.turn_id != turn_id {
let expected_turn_id = pending.turn_id.clone();
state
.pending_overrides
.insert(review_id.to_string(), pending);
return Err(GuardianReviewOverrideError::TurnMismatch { expected_turn_id });
}
pending
.sender
.send(decision)
.map_err(|_| GuardianReviewOverrideError::NotFound)
}
pub(crate) async fn shutdown(&self) {
let (review_session, ephemeral_reviews) = {
let mut state = self.state.lock().await;
@@ -414,7 +475,7 @@ impl GuardianReviewSessionManager {
let snapshot = state.last_committed_fork_snapshot.as_ref()?;
match &snapshot.initial_history {
InitialHistory::Forked(items) => Some(items.clone()),
InitialHistory::New | InitialHistory::Resumed(_) => None,
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Resumed(_) => None,
}
}

View File

@@ -24,7 +24,10 @@ use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::GuardianReviewOverrideDecision;
use codex_protocol::protocol::GuardianRiskLevel;
use codex_protocol::protocol::GuardianUserAuthorization;
use codex_protocol::protocol::ReviewDecision;
@@ -674,6 +677,170 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() {
);
}
fn guardian_override_test_request(id: &str) -> GuardianApprovalRequest {
GuardianApprovalRequest::Shell {
id: id.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: PathBuf::from("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
}
}
async fn recv_guardian_assessment_with_status(
rx: &async_channel::Receiver<codex_protocol::protocol::Event>,
status: GuardianAssessmentStatus,
) -> GuardianAssessmentEvent {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let event = rx.recv().await.expect("event channel should remain open");
if let EventMsg::GuardianAssessment(assessment) = event.msg
&& assessment.status == status
{
return assessment;
}
}
})
.await
.expect("guardian assessment event should arrive")
}
#[tokio::test]
async fn guardian_review_override_validates_pending_turn_id() {
let (session, _) = crate::codex::make_session_and_context().await;
let override_rx = session
.guardian_review_session
.register_pending_override("review-1".to_string(), "turn-1".to_string())
.await;
let err = session
.guardian_review_session
.override_review(
"review-1",
"turn-2",
GuardianReviewOverrideDecision::Approve,
)
.await
.expect_err("turn mismatch should fail");
assert!(matches!(
err,
super::review_session::GuardianReviewOverrideError::TurnMismatch { expected_turn_id }
if expected_turn_id == "turn-1"
));
session
.guardian_review_session
.override_review(
"review-1",
"turn-1",
GuardianReviewOverrideDecision::Approve,
)
.await
.expect("expected turn should be accepted");
assert_eq!(
override_rx.await.expect("override decision"),
GuardianReviewOverrideDecision::Approve
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_decline_override_preempts_automatic_review() -> anyhow::Result<()> {
let (_gate_tx, gate_rx) = tokio::sync::oneshot::channel();
let (server, _) = start_streaming_sse_server(vec![vec![
StreamingSseChunk {
gate: None,
body: sse(vec![ev_response_created("resp-guardian-override")]),
},
StreamingSseChunk {
gate: Some(gate_rx),
body: sse(vec![
ev_assistant_message(
"msg-guardian-override",
&serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "guardian would allow",
})
.to_string(),
),
ev_completed("resp-guardian-override"),
]),
},
]])
.await;
let (mut session, mut turn, rx) = crate::codex::make_session_and_context_with_rx().await;
let mut config = (*turn.config).clone();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
config.user_instructions = None;
let config = Arc::new(config);
let models_manager = Arc::new(test_support::models_manager_with_provider(
config.codex_home.clone(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
));
Arc::get_mut(&mut session)
.expect("session should be uniquely owned")
.services
.models_manager = models_manager;
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
turn_mut.config = Arc::clone(&config);
turn_mut.provider = config.model_provider.clone();
turn_mut.user_instructions = None;
seed_guardian_parent_history(&session, &turn).await;
let session_for_review = Arc::clone(&session);
let turn_for_review = Arc::clone(&turn);
let review = tokio::spawn(async move {
review_approval_request(
&session_for_review,
&turn_for_review,
"review-shell-guardian-override".to_string(),
guardian_override_test_request("shell-guardian-override"),
/*retry_reason*/ None,
)
.await
});
let started =
recv_guardian_assessment_with_status(&rx, GuardianAssessmentStatus::InProgress).await;
session
.guardian_review_session
.override_review(
&started.id,
&started.turn_id,
GuardianReviewOverrideDecision::Decline,
)
.await
.expect("override should be accepted");
assert_eq!(review.await?, ReviewDecision::Denied);
let completed =
recv_guardian_assessment_with_status(&rx, GuardianAssessmentStatus::Denied).await;
assert_eq!(completed.id, started.id);
assert_eq!(
completed.decision_source,
Some(GuardianAssessmentDecisionSource::User)
);
assert_eq!(completed.risk_level, None);
assert_eq!(completed.user_authorization, None);
assert_eq!(
guardian_rejection_message(session.as_ref(), &started.id).await,
concat!(
"This action was declined before the automatic approval review completed.\n",
"Reason: User preempted the automatic guardian review and declined this review.\n",
"The agent must not attempt to achieve the same outcome via workaround, ",
"indirect execution, or policy circumvention. ",
"Proceed only with a materially safer alternative, ",
"or if the user explicitly approves the action after being informed of the risk. ",
"Otherwise, stop and request user input."
)
);
server.shutdown().await;
Ok(())
}
#[tokio::test]
async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
let (session, turn, rx) = crate::codex::make_session_and_context_with_rx().await;

View File

@@ -112,6 +112,15 @@ pub enum GuardianAssessmentStatus {
#[serde(rename_all = "snake_case")]
pub enum GuardianAssessmentDecisionSource {
Agent,
#[serde(alias = "client_override")]
User,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
pub enum GuardianReviewOverrideDecision {
Approve,
Decline,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]

View File

@@ -69,6 +69,7 @@ pub use crate::approvals::GuardianAssessmentDecisionSource;
pub use crate::approvals::GuardianAssessmentEvent;
pub use crate::approvals::GuardianAssessmentStatus;
pub use crate::approvals::GuardianCommandSource;
pub use crate::approvals::GuardianReviewOverrideDecision;
pub use crate::approvals::GuardianRiskLevel;
pub use crate::approvals::GuardianUserAuthorization;
pub use crate::approvals::NetworkApprovalContext;

View File

@@ -3402,11 +3402,16 @@ impl ChatWidget {
}
if ev.status == GuardianAssessmentStatus::Approved {
let actor = if ev.decision_source == Some(GuardianAssessmentDecisionSource::User) {
history_cell::ApprovalDecisionActor::User
} else {
history_cell::ApprovalDecisionActor::Guardian
};
let cell = if let Some(command) = guardian_command(&ev.action) {
history_cell::new_approval_decision_cell(
command,
codex_protocol::protocol::ReviewDecision::Approved,
history_cell::ApprovalDecisionActor::Guardian,
actor,
)
} else if let Some(summary) = guardian_action_summary(&ev.action) {
history_cell::new_guardian_approved_action_request(summary)
@@ -3424,11 +3429,16 @@ impl ChatWidget {
if ev.status != GuardianAssessmentStatus::Denied {
return;
}
let actor = if ev.decision_source == Some(GuardianAssessmentDecisionSource::User) {
history_cell::ApprovalDecisionActor::User
} else {
history_cell::ApprovalDecisionActor::Guardian
};
let cell = if let Some(command) = guardian_command(&ev.action) {
history_cell::new_approval_decision_cell(
command,
codex_protocol::protocol::ReviewDecision::Denied,
history_cell::ApprovalDecisionActor::Guardian,
actor,
)
} else {
match &ev.action {
@@ -6953,6 +6963,9 @@ impl ChatWidget {
codex_app_server_protocol::AutoReviewDecisionSource::Agent => {
GuardianAssessmentDecisionSource::Agent
}
codex_app_server_protocol::AutoReviewDecisionSource::User => {
GuardianAssessmentDecisionSource::User
}
}),
action: action.into(),
});