From e1eb98461cd42730e7b1a890c3100303d7091bc2 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 7 Sep 2026 10:57:55 +0000 Subject: [PATCH] Route approvals through the extension decision API (#43432) ## What changed - Let approval extensions choose between cached approval, synchronous review, and a user prompt for tool and permission requests, while core enforces mandatory Guardian and fresh-review requirements. - Carry an optional `review_reason` on Guardian assessment events and use it to trigger strict-review notifications, retaining the fallback for older events. - Require synchronous review when cached evidence has an unusable encrypted parent compaction. ## Testing Add regression coverage for manual prompts and cached approvals with non-UTF-8 working directories. Update approval contributors in existing tests to use the decision API. GitOrigin-RevId: a4e66416070bdaa881daa41429c49021629f44c1 --- .../app-server-exports-stable.json.zst | Bin 148173 -> 148308 bytes .../src/protocol/item_builders_tests.rs | 2 + .../src/protocol/thread_history.rs | 3 + .../app-server/src/bespoke_event_handling.rs | 23 ++- codex-rs/core/src/guardian/coverage.rs | 33 ++++ codex-rs/core/src/guardian/decision.rs | 184 ++++++++++++++++++ codex-rs/core/src/guardian/mod.rs | 27 +-- codex-rs/core/src/guardian/review.rs | 55 ++---- codex-rs/core/src/guardian/runtime.rs | 68 ++++++- codex-rs/core/src/guardian/tests.rs | 1 + codex-rs/core/src/session/mcp.rs | 2 + codex-rs/core/src/session/mod.rs | 97 +++++---- codex-rs/core/src/tools/approvals.rs | 183 +++++++---------- codex-rs/core/src/tools/approvals_tests.rs | 103 ++++++++++ codex-rs/core/tests/suite/auto_review.rs | 31 ++- .../core/tests/suite/context_annotations.rs | 1 + codex-rs/core/tests/suite/guardian_review.rs | 16 +- .../guardian-v2/src/async_scorer/approval.rs | 6 +- .../guardian-v2/src/async_scorer/extension.rs | 4 +- codex-rs/protocol/src/approvals.rs | 4 + codex-rs/tui/src/auto_review_denials.rs | 1 + .../tui/src/chatwidget/protocol_requests.rs | 1 + codex-rs/tui/src/chatwidget/tests/guardian.rs | 9 + .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + 24 files changed, 605 insertions(+), 250 deletions(-) create mode 100644 codex-rs/core/src/guardian/coverage.rs create mode 100644 codex-rs/core/src/guardian/decision.rs diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index d93bfefb7dd225185002b81457c380d10e00b810..10d00f3a35194480bc6b308fe50216a3e759929d 100644 GIT binary patch delta 18995 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 - .is_some() + && match assessment.review_reason { + Some(reason) => matches!( + reason, + codex_protocol::approvals::GuardianReviewReason::ElevatedRisk + | codex_protocol::approvals::GuardianReviewReason::StaleScore + | codex_protocol::approvals::GuardianReviewReason::IncompatibleCompaction + | codex_protocol::approvals::GuardianReviewReason::ScoringFailure + | codex_protocol::approvals::GuardianReviewReason::AuthorizationChanged + ), + // MCP elicitations migrate to per-approval reasons next. + None => conversation + .thread_extension_data() + .remove::() + .is_some(), + } { outgoing .send_server_notification(ServerNotification::StrictReviewRequired( @@ -2429,6 +2440,7 @@ mod tests { GuardianAssessmentStatus::Aborted => (None, None, None), }; GuardianAssessmentEvent { + review_reason: None, id: format!("review-{id}"), target_item_id: Some(id.to_string()), plugin_id: Some("sample@openai-curated".to_string()), @@ -2498,6 +2510,7 @@ mod tests { &conversation_id, "turn-from-event", &GuardianAssessmentEvent { + review_reason: None, id: "review-1".to_string(), target_item_id: Some("item-1".to_string()), plugin_id: None, @@ -2546,6 +2559,7 @@ mod tests { &conversation_id, "turn-from-event", &GuardianAssessmentEvent { + review_reason: None, id: "review-2".to_string(), target_item_id: Some("item-2".to_string()), plugin_id: None, @@ -2602,6 +2616,7 @@ mod tests { &conversation_id, "turn-from-event", &GuardianAssessmentEvent { + review_reason: None, id: "review-3".to_string(), target_item_id: None, plugin_id: None, diff --git a/codex-rs/core/src/guardian/coverage.rs b/codex-rs/core/src/guardian/coverage.rs new file mode 100644 index 0000000000..f18aa2bf0c --- /dev/null +++ b/codex-rs/core/src/guardian/coverage.rs @@ -0,0 +1,33 @@ +//! Supplies action categories to the review extension and enforces host-owned requirements. + +use super::GuardianApprovalRequest; +use crate::tools::sandboxing::ApprovalAction; +use codex_protocol::openai_models::GuardianScope; + +impl GuardianApprovalRequest { + pub(crate) fn guardian_scope(&self) -> GuardianScope { + match self { + Self::ExecCommand { .. } | Self::WriteStdin { .. } => GuardianScope::Shell, + #[cfg(unix)] + Self::Execve { .. } => GuardianScope::Shell, + Self::ApplyPatch { .. } => GuardianScope::FileChanges, + Self::McpToolCall { server, .. } => GuardianScope::for_mcp_server(server), + Self::NetworkAccess { .. } => GuardianScope::Network, + Self::RequestPermissions { .. } => GuardianScope::Permissions, + } + } +} + +impl ApprovalAction { + pub(crate) fn guardian_scope(&self) -> GuardianScope { + match self { + Self::ExecCommand { .. } | Self::WriteStdin { .. } => GuardianScope::Shell, + #[cfg(unix)] + Self::Execve { .. } => GuardianScope::Shell, + Self::ApplyPatch { .. } => GuardianScope::FileChanges, + Self::McpToolCall { server, .. } => GuardianScope::for_mcp_server(server), + Self::NetworkAccess { .. } => GuardianScope::Network, + Self::RequestPermissions { .. } => GuardianScope::Permissions, + } + } +} diff --git a/codex-rs/core/src/guardian/decision.rs b/codex-rs/core/src/guardian/decision.rs new file mode 100644 index 0000000000..11f9b641f2 --- /dev/null +++ b/codex-rs/core/src/guardian/decision.rs @@ -0,0 +1,184 @@ +//! Calls the decision extension for each approval and enforces host constraints. +//! The synchronous service captures one action; no outcome is stored by tool-call ID. + +use codex_async_utils::THREAD_STACK_SIZE_BYTES; +use codex_extension_api::ApprovalDecision; +use codex_extension_api::ApprovalDecisionInput; +use codex_extension_api::SynchronousApprovalReviewer; +use codex_protocol::approvals::GuardianReviewReason; +use codex_protocol::protocol::ReviewDecision; +use std::sync::Arc; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +use super::ApprovalRequestReasons; +use super::GuardianApprovalRequest; +use super::GuardianReviewContext; +use super::GuardianReviewOptions; +use super::review::record_guardian_non_denial; +use super::runtime::ReviewAction; +use super::runtime::ReviewRuntime; +use crate::session::session::Session; + +pub(crate) fn spawn_approval_decision( + session: Arc, + context: impl Into, + review_id: String, + request: impl Into, + reasons: ApprovalRequestReasons, + options: GuardianReviewOptions, +) -> oneshot::Receiver> { + let context: GuardianReviewContext = context.into(); + let request: ReviewAction = request.into(); + let (tx, rx) = oneshot::channel(); + let runtime = session.services.runtime_handle.clone(); + let spawn_result = std::thread::Builder::new() + .name("codex-approval-review".to_string()) + .stack_size(THREAD_STACK_SIZE_BYTES) + .spawn(move || { + let decision = runtime.block_on(decide_approval( + session, context, review_id, request, reasons, options, + )); + let _ = tx.send(decision); + }); + if let Err(err) = spawn_result { + tracing::error!(%err, "failed to spawn automatic approval review worker"); + } + rx +} + +/// `None` requests the existing user flow. No contributor is never an implicit allow. +pub(crate) async fn decide_approval( + session: Arc, + context: impl Into, + review_id: String, + request: impl Into, + reasons: ApprovalRequestReasons, + options: GuardianReviewOptions, +) -> Option { + let context = context.into(); + let request = request.into(); + let turn = context.turn(); + let requirements = turn.config.config_layer_stack.requirements(); + let model_requires_review = + requirements.auto_review_required_for_model(&turn.model_info().slug); + let require_guardian = options.require_guardian + || model_requires_review + || requirements + .approvals_reviewer + .can_set(&codex_protocol::config_types::ApprovalsReviewer::User) + .is_err(); + let require_fresh_review = options.require_synchronous_review + || model_requires_review + && !turn + .config + .features + .enabled(codex_features::Feature::GuardianV2) + || options + .external_cancel + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + || 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, .. }) + if sandbox_permissions.requires_escalated_permissions()); + let full_access = context.environments().has_full_access( + context.approval_policy, + &turn.config.permissions.effective_permission_profile(), + ); + if full_access { + return Some(if let Err(decision) = request.validate(&context) { + decision + } else if options + .external_cancel + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + ReviewDecision::Abort + } else { + ReviewDecision::Approved + }); + } + let action = match &request.action { + Ok(action) => action, + Err(_) => { + return Some(ReviewDecision::denied( + "automatic approval review could not prepare the action", + )); + } + }; + let runtime = ReviewRuntime { + session: Arc::clone(&session), + context: context.clone(), + review_id: review_id.clone(), + request: request.clone(), + reasons, + options, + }; + let input = ApprovalDecisionInput { + approval_id: &review_id, + action, + thread_id: session.thread_id, + thread_store: &session.services.thread_extension_data, + category: request.category, + approval_policy: context.approval_policy, + approvals_reviewer: context.approvals_reviewer, + require_guardian, + require_fresh_review, + full_access, + metrics: Some(crate::session::extension_metrics::from_session_telemetry( + turn.session_telemetry.clone(), + )), + synchronous_reviewer: &runtime, + }; + match session.services.extensions.decide_approval(&input).await { + Some(ApprovalDecision::Reviewed(decision)) => Some(decision), + Some(ApprovalDecision::Allow) if !require_fresh_review => { + let request = match request.validate(&context) { + Ok(request) => request, + Err(decision) => return Some(decision), + }; + let turn_id = super::approval_request::guardian_request_turn_id(request, &turn.sub_id); + if session + .services + .thread_extension_data + .get::() + .is_some() + { + session + .services + .analytics_events_client + .track_guardian_v2_event(codex_analytics::GuardianV2Event { + thread_id: session.thread_id.to_string(), + turn_id: turn_id.to_owned(), + item_id: super::approval_request::guardian_request_target_item_id(request) + .map(str::to_owned), + model: Some(turn.model_info().slug.clone()), + occurred_at_ms: codex_analytics::now_unix_millis(), + kind: codex_analytics::GuardianV2EventKind::FastDecision { + decision: "approved", + }, + }); + } + record_guardian_non_denial(&session, turn_id).await; + Some(ReviewDecision::Approved) + } + Some(ApprovalDecision::Allow) => { + Some(runtime.review(GuardianReviewReason::FreshRequired).await) + } + Some(ApprovalDecision::AskUser) if !require_guardian => None, + None if !require_guardian + && !super::review::routes_approval_policy_to_guardian( + context.approval_policy, + context.approvals_reviewer, + ) => + { + None + } + None | Some(ApprovalDecision::AskUser) => { + Some(runtime.review(GuardianReviewReason::Policy).await) + } + } +} diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 57fdcd7737..3701c86add 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -1,18 +1,10 @@ -//! Guardian review decides whether an `on-request` approval should be granted -//! automatically instead of shown to the user. -//! Full Access (`never` approvals with a disabled sandbox) approves without review. -//! -//! High-level approach: -//! 1. Reconstruct a compact transcript that preserves user intent plus the most -//! relevant recent assistant and tool context. -//! 2. Ask a dedicated guardian review session to assess the exact planned -//! action and return strict JSON. -//! The guardian clones the parent config, so it inherits any managed -//! network proxy / allowlist that the parent turn already had. -//! 3. Fail closed on timeout, execution failure, or malformed output. -//! 4. Apply the guardian's explicit allow/deny outcome. +//! Hosts approval decisions and the isolated synchronous reviewer. +//! The extension chooses policy and evidence; core enforces permissions and mandatory +//! review requirements. Each approval retains its issuing context and cancellation. mod approval_request; +mod coverage; +mod decision; mod feedback; mod metrics; mod prompt; @@ -43,6 +35,8 @@ pub(crate) use approval_request::GuardianMcpAnnotations; pub(crate) use approval_request::GuardianNetworkAccessTrigger; #[cfg(test)] pub(crate) use approval_request::guardian_approval_request_to_json; +pub(crate) use decision::decide_approval; +pub(crate) use decision::spawn_approval_decision; pub(crate) use prompt::BUNDLED_GUARDIAN_POLICY; pub(crate) use prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE; pub(crate) use prompt::guardian_truncate_text; @@ -52,13 +46,14 @@ pub(crate) use review::is_basic_session_source; pub(crate) use review::new_guardian_review_id; #[cfg(test)] pub(crate) use review::record_guardian_denial_for_test; +#[cfg(test)] pub(crate) use review::review_approval_request; pub(crate) use review::review_approval_request_with_cancel; pub(crate) use review::routes_approval_policy_to_guardian; pub(crate) use review::routes_approval_to_guardian; -pub(crate) use review::spawn_approval_request_review; pub(crate) use review_session::GuardianReviewSessionManager; pub(crate) use review_session::prompt_cache_key_override_for_review_session; +pub(crate) use runtime::ReviewAction; pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90); pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian"; @@ -83,9 +78,7 @@ const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; /// Background network approvals and Unix interception use the active task's resolved settings. /// Startup reviewer prewarming intentionally uses turn-only inputs because it has no issuing step. /// -/// MCP elicitation reviews still use turn-only inputs. -/// TODO(sayan): See if we can find a way to model those as StepContext as well without holding -/// step-scoped things past their lifetime (like MCP bindings) +/// MCP elicitation reviews continue to use turn-only inputs. #[derive(Clone)] pub(crate) struct GuardianReviewContext { /// The response currently handled in this execution context. diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 83384c0c38..3347abe33d 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -7,7 +7,6 @@ use codex_analytics::GuardianReviewTrackContext; use codex_analytics::GuardianReviewedAction; use codex_analytics::GuardianV2Event; use codex_analytics::GuardianV2EventKind; -use codex_async_utils::THREAD_STACK_SIZE_BYTES; use codex_core_plugins::PluginCommandAttribution; use codex_extension_api::GuardianV2Enabled; use codex_extension_api::ThreadIdleCause; @@ -29,10 +28,10 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; +#[cfg(test)] use futures::future::BoxFuture; use std::sync::Arc; use std::time::Duration; -use tokio::sync::oneshot; use tokio::time::Instant; use tokio::time::sleep_until; use tokio_util::sync::CancellationToken; @@ -268,7 +267,7 @@ fn track_guardian_review( .track_guardian_review(tracking, result, completed_at_ms); } -async fn record_guardian_non_denial(session: &Arc, turn_id: &str) { +pub(super) async fn record_guardian_non_denial(session: &Arc, turn_id: &str) { session .services .guardian_rejection_circuit_breaker @@ -432,21 +431,17 @@ async fn run_guardian_review( session, context, review_id, - request, + request: request.into(), reasons, options, }; - codex_extension_api::SynchronousApprovalReviewer::review( - &runtime, - codex_protocol::approvals::GuardianReviewReason::Policy, - ) - .await + run_synchronous_review(runtime, /*review_reason*/ None).await } /// Runs the existing synchronous reviewer without choosing policy or reusing a score. pub(super) async fn run_synchronous_review( runtime: super::runtime::ReviewRuntime, - _review_reason: codex_protocol::approvals::GuardianReviewReason, + review_reason: Option, ) -> ReviewDecision { let super::runtime::ReviewRuntime { session, @@ -456,12 +451,17 @@ pub(super) async fn run_synchronous_review( reasons, options, } = runtime; + let request = match request.validate(&context) { + Ok(request) => request.clone(), + Err(decision) => return decision, + }; let turn = Arc::clone(context.turn()); let GuardianReviewOptions { plugin_attribution_override, approval_request_source, external_cancel, require_synchronous_review: _, + require_guardian: _, } = options; // Bound executor attribution and the Guardian session with one review deadline. let deadline = Instant::now() + GUARDIAN_REVIEW_TIMEOUT; @@ -518,6 +518,7 @@ pub(super) async fn run_synchronous_review( .send_event( turn.as_ref(), EventMsg::GuardianAssessment(GuardianAssessmentEvent { + review_reason, id: review_id.clone(), target_item_id: target_item_id.clone(), plugin_id: plugin_id.clone(), @@ -557,6 +558,7 @@ pub(super) async fn run_synchronous_review( .send_event( turn.as_ref(), EventMsg::GuardianAssessment(GuardianAssessmentEvent { + review_reason, id: review_id, target_item_id, plugin_id: plugin_id.clone(), @@ -696,6 +698,7 @@ pub(super) async fn run_synchronous_review( .send_event( turn.as_ref(), EventMsg::GuardianAssessment(GuardianAssessmentEvent { + review_reason, id: review_id, target_item_id, plugin_id: plugin_id.clone(), @@ -733,6 +736,7 @@ pub(super) async fn run_synchronous_review( .send_event( turn.as_ref(), EventMsg::GuardianAssessment(GuardianAssessmentEvent { + review_reason, id: review_id, target_item_id, plugin_id: plugin_id.clone(), @@ -818,6 +822,7 @@ pub(super) async fn run_synchronous_review( GuardianAssessmentStatus::Denied }; let assessment_event = GuardianAssessmentEvent { + review_reason, id: review_id, target_item_id, plugin_id: plugin_id.clone(), @@ -879,6 +884,8 @@ pub(super) async fn run_synchronous_review( #[derive(Clone)] pub(crate) struct GuardianReviewOptions { + /// Requires Guardian rather than a manual approval; cached evidence may still satisfy it. + pub(crate) require_guardian: bool, pub(crate) plugin_attribution_override: Option, pub(crate) approval_request_source: GuardianApprovalRequestSource, pub(crate) external_cancel: Option, @@ -887,6 +894,7 @@ pub(crate) struct GuardianReviewOptions { } /// Public entrypoint for approval requests that should be reviewed by guardian. +#[cfg(test)] pub(crate) async fn review_approval_request( session: &Arc, context: impl Into, @@ -902,6 +910,7 @@ pub(crate) async fn review_approval_request( request, reasons, GuardianReviewOptions { + require_guardian: false, plugin_attribution_override: None, approval_request_source: GuardianApprovalRequestSource::MainTurn, external_cancel: None, @@ -933,32 +942,6 @@ pub(crate) async fn review_approval_request_with_cancel( .await } -pub(crate) fn spawn_approval_request_review( - session: Arc, - context: impl Into, - review_id: String, - request: GuardianApprovalRequest, - reasons: ApprovalRequestReasons, - options: GuardianReviewOptions, -) -> oneshot::Receiver { - let context = context.into(); - let (tx, rx) = oneshot::channel(); - let runtime = session.services.runtime_handle.clone(); - let spawn_result = std::thread::Builder::new() - .name("codex-approval-review".to_string()) - .stack_size(THREAD_STACK_SIZE_BYTES) - .spawn(move || { - let decision = runtime.block_on(run_guardian_review( - session, context, review_id, request, reasons, options, - )); - let _ = tx.send(decision); - }); - if let Err(err) = spawn_result { - tracing::error!(%err, "failed to spawn automatic approval review worker"); - } - rx -} - pub(super) struct GuardianReviewSessionConfig { pub(super) spawn_config: crate::config::Config, pub(super) node_repl_policy: GuardianNodeReplPolicy, diff --git a/codex-rs/core/src/guardian/runtime.rs b/codex-rs/core/src/guardian/runtime.rs index d4b353371e..093a998a33 100644 --- a/codex-rs/core/src/guardian/runtime.rs +++ b/codex-rs/core/src/guardian/runtime.rs @@ -1,4 +1,4 @@ -//! Binds the existing synchronous reviewer to one captured approval action. +//! Binds the existing synchronous reviewer to one approval action. use codex_extension_api::ExtensionFuture; use codex_extension_api::SynchronousApprovalReviewer; @@ -9,15 +9,77 @@ use super::ApprovalRequestReasons; use super::GuardianApprovalRequest; use super::GuardianReviewContext; use super::GuardianReviewOptions; +use super::approval_request::guardian_approval_request_to_json; use super::review::run_synchronous_review; use crate::session::session::Session; +/// Carries the original action even when the legacy synchronous renderer cannot handle its paths. +/// This lets the extension return AskUser before invoking that renderer. +#[derive(Clone)] +pub(crate) struct ReviewAction { + pub(super) action: Result, + pub(super) category: codex_protocol::openai_models::GuardianScope, + pub(super) request: Result, +} + +impl From for ReviewAction { + fn from(request: GuardianApprovalRequest) -> Self { + Self { + action: guardian_approval_request_to_json(&request).map_err(|error| error.to_string()), + category: request.guardian_scope(), + request: Ok(request), + } + } +} + +impl ReviewAction { + pub(crate) fn from_approval_action( + action: crate::tools::sandboxing::ApprovalAction, + exec_command_cwd_convention: Option, + ) -> Self { + let category = action.guardian_scope(); + let original = serde_json::to_value(&action).map_err(|error| error.to_string()); + match action.into_guardian_request(exec_command_cwd_convention) { + Ok(request) => Self::from(request), + Err(error) => Self { + action: original, + category, + request: Err(error.to_string()), + }, + } + } +} + +impl ReviewAction { + /// Preserve the checks previously made before entering Guardian from tool approvals. + pub(super) fn validate( + &self, + context: &GuardianReviewContext, + ) -> Result<&GuardianApprovalRequest, ReviewDecision> { + let request = self.request.as_ref().map_err(|error| { + tracing::error!(%error, "failed to build automatic approval action"); + ReviewDecision::denied("automatic approval review could not prepare the action") + })?; + if let GuardianApprovalRequest::WriteStdin { environment_id, .. } = request + && !context + .environments() + .turn_environments() + .any(|environment| environment.selection.environment_id == *environment_id) + { + return Err(ReviewDecision::denied( + "automatic approval review cannot access the terminal's environment; select it before retrying", + )); + } + Ok(request) + } +} + #[derive(Clone)] pub(super) struct ReviewRuntime { pub(super) session: Arc, pub(super) context: GuardianReviewContext, pub(super) review_id: String, - pub(super) request: GuardianApprovalRequest, + pub(super) request: ReviewAction, pub(super) reasons: ApprovalRequestReasons, pub(super) options: GuardianReviewOptions, } @@ -27,6 +89,6 @@ impl SynchronousApprovalReviewer for ReviewRuntime { &self, reason: codex_protocol::approvals::GuardianReviewReason, ) -> ExtensionFuture<'_, ReviewDecision> { - Box::pin(run_synchronous_review(self.clone(), reason)) + Box::pin(run_synchronous_review(self.clone(), Some(reason))) } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 88f4f17a20..71190f951d 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1696,6 +1696,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { }, /*retry_reason*/ None, GuardianReviewOptions { + require_guardian: false, plugin_attribution_override: None, approval_request_source: GuardianApprovalRequestSource::MainTurn, external_cancel: Some(cancel_token), diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 1444c1e64c..f4feaafbc2 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -891,6 +891,7 @@ async fn review_guardian_mcp_elicitation( guardian_request, /*retry_reason*/ None, crate::guardian::GuardianReviewOptions { + require_guardian: false, plugin_attribution_override: None, approval_request_source: codex_analytics::GuardianApprovalRequestSource::MainTurn, external_cancel: Some(cancellation_token), @@ -984,6 +985,7 @@ async fn review_guardian_mcp_elicitation( guardian_request, /*retry_reason*/ None, crate::guardian::GuardianReviewOptions { + require_guardian: false, plugin_attribution_override: None, approval_request_source: codex_analytics::GuardianApprovalRequestSource::MainTurn, external_cancel: Some(cancellation_token), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 24fd9cf629..849f07c935 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2780,7 +2780,6 @@ impl Session { ) -> Option { let turn_context = &step_context.turn; let approval_policy = step_context.settings.approval_policy(); - let approvals_reviewer = step_context.settings.approvals_reviewer(); let Some(environment) = step_context .environments .turn_environments() @@ -2827,7 +2826,6 @@ impl Session { strict_auto_review: false, }); }; - if crate::guardian::routes_approval_policy_to_guardian(approval_policy, approvals_reviewer) { let originating_turn_state = { let active = self.active_turn.lock().await; @@ -2836,13 +2834,13 @@ impl Session { let action = ApprovalAction::RequestPermissions { id: call_id.clone(), turn_id: turn_context.sub_id.clone(), - reason: args.reason, + reason: args.reason.clone(), permissions: requested_permissions.clone(), }; let approval_context = ApprovalContext { review_context: crate::guardian::GuardianReviewContext::from(step_context), cancellation_token: Some(cancellation_token.clone()), - call_id, + call_id: call_id.clone(), tool_name: ToolName::plain("request_permissions"), strict_auto_review: false, approval_reason: None, @@ -2857,54 +2855,53 @@ impl Session { &approval_context, ) => decision, }; - let response = match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { - RequestPermissionsResponse { - permissions: requested_permissions.clone(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, + if let Some(decision) = decision { + let (permissions, scope) = match decision { + ReviewDecision::Approved + | ReviewDecision::ApprovedExecpolicyAmendment { .. } + | ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: + NetworkPolicyAmendment { + action: NetworkPolicyRuleAction::Allow, + .. + }, + } => (requested_permissions.clone(), PermissionGrantScope::Turn), + ReviewDecision::ApprovedForSession => { + (requested_permissions.clone(), PermissionGrantScope::Session) } - } - ReviewDecision::ApprovedForSession => RequestPermissionsResponse { - permissions: requested_permissions.clone(), - scope: PermissionGrantScope::Session, + ReviewDecision::ApprovedMcpPolicyAmendment + | ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: + NetworkPolicyAmendment { + action: NetworkPolicyRuleAction::Deny, + .. + }, + } + | ReviewDecision::Abort + | ReviewDecision::Denied { .. } + | ReviewDecision::TimedOut => ( + RequestPermissionProfile::default(), + PermissionGrantScope::Turn, + ), + }; + let response = RequestPermissionsResponse { + permissions, + scope, strict_auto_review: false, - }, - ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment, - } => match network_policy_amendment.action { - NetworkPolicyRuleAction::Allow => RequestPermissionsResponse { - permissions: requested_permissions.clone(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, - }, - NetworkPolicyRuleAction::Deny => RequestPermissionsResponse { - permissions: RequestPermissionProfile::default(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, - }, - }, - ReviewDecision::ApprovedMcpPolicyAmendment - | ReviewDecision::Abort - | ReviewDecision::Denied { .. } - | ReviewDecision::TimedOut => RequestPermissionsResponse { - permissions: RequestPermissionProfile::default(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, - }, - }; - let response = Self::normalize_request_permissions_response( - requested_permissions, - response, - &context, - ); - self.record_granted_request_permissions_for_turn( - &response, - &environment.selection.environment_id, - originating_turn_state.as_ref(), - ) - .await; - return Some(response); + }; + let response = Self::normalize_request_permissions_response( + requested_permissions, + response, + &context, + ); + self.record_granted_request_permissions_for_turn( + &response, + &environment.selection.environment_id, + originating_turn_state.as_ref(), + ) + .await; + return Some(response); + } } let _elicitation = self.services.elicitations.register(); diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index 7e7c68e13e..8675ead36d 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -5,12 +5,10 @@ use crate::exec_policy::prompt_is_rejected_by_policy; use crate::guardian::GuardianNetworkAccessTrigger; use crate::guardian::GuardianReviewContext; use crate::guardian::GuardianReviewOptions; +use crate::guardian::decide_approval; use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request; -use crate::guardian::review_approval_request_with_cancel; -use crate::guardian::routes_approval_policy_to_guardian; -use crate::guardian::spawn_approval_request_review; +use crate::guardian::spawn_approval_decision; use crate::hook_runtime::run_permission_request_hooks; use crate::mcp_tool_call::request_mcp_tool_user_approval; use crate::sandboxing::SandboxPermissions; @@ -64,12 +62,14 @@ pub(crate) struct ApprovalContext { pub(crate) network_approval_context: Option, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +#[serde(tag = "tool", rename_all = "snake_case")] pub(crate) enum ApprovalAction { ExecCommand { id: String, environment_id: String, command: Vec, + #[serde(skip_serializing)] hook_command: String, cwd: PathUri, sandbox_permissions: SandboxPermissions, @@ -107,6 +107,7 @@ pub(crate) enum ApprovalAction { cwd: PathUri, files: Vec, patch: String, + #[serde(skip_serializing)] changes: Arc>, permissions_preapproved: bool, }, @@ -122,6 +123,7 @@ pub(crate) enum ApprovalAction { tool_title: Option, tool_description: Option, annotations: Option, + #[serde(skip_serializing)] hook_tool_name: HookToolName, approval_policy: AskForApproval, reviewer: ApprovalsReviewer, @@ -138,7 +140,9 @@ pub(crate) enum ApprovalAction { protocol: NetworkApprovalProtocol, port: u16, trigger: Option, + #[serde(skip_serializing)] hook_command: String, + #[serde(skip_serializing)] hook_run_id: String, command: Vec, cwd: AbsolutePathBuf, @@ -414,22 +418,6 @@ impl ApprovalAction { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ApprovalReviewer { - Guardian, - User, -} - -impl ApprovalReviewer { - fn for_policy(approval_policy: AskForApproval, reviewer: ApprovalsReviewer) -> Self { - if routes_approval_policy_to_guardian(approval_policy, reviewer) { - Self::Guardian - } else { - Self::User - } - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ApprovalResolutionSource { Hook, @@ -556,51 +544,33 @@ impl Session { action: ApprovalAction, ctx: &ApprovalContext, ) -> ApprovalResolution { - let reviewer = if ctx.strict_auto_review { - ApprovalReviewer::Guardian - } else if let ApprovalAction::McpToolCall { - approval_policy, - reviewer, - .. - } = &action - { - ApprovalReviewer::for_policy(*approval_policy, *reviewer) + if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await { + ApprovalResolution { + decision, + source: ApprovalResolutionSource::Guardian, + } } else { - ApprovalReviewer::for_policy( - ctx.review_context.approval_policy, - ctx.review_context.approvals_reviewer, - ) - }; - - let decision = match reviewer { - ApprovalReviewer::Guardian => self.request_guardian_approval(action, ctx).await, - ApprovalReviewer::User => self.request_user_approval(&action, ctx).await, - }; - let source = match reviewer { - ApprovalReviewer::Guardian => ApprovalResolutionSource::Guardian, - ApprovalReviewer::User => ApprovalResolutionSource::User, - }; - ApprovalResolution { decision, source } + ApprovalResolution { + decision: self.request_user_approval(&action, ctx).await, + source: ApprovalResolutionSource::User, + } + } } pub(crate) async fn request_guardian_approval( self: &Arc, action: ApprovalAction, ctx: &ApprovalContext, - ) -> ReviewDecision { - // Guardian inherits only the current turn's ready environments. A retained - // terminal handle may outlive its selection, but must not be reviewed in - // a different environment that happens to have the same launch directory. - if let ApprovalAction::WriteStdin { environment_id, .. } = &action - && !ctx - .review_context - .environments() - .turn_environments() - .any(|environment| environment.selection.environment_id == *environment_id) + ) -> Option { + let mut context = ctx.review_context.clone(); + if let ApprovalAction::McpToolCall { + approval_policy, + reviewer, + .. + } = &action { - return ReviewDecision::denied( - "automatic approval review cannot access the terminal's environment; select it before retrying", - ); + context.approval_policy = *approval_policy; + context.approvals_reviewer = *reviewer; } let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. }); let review_id = new_guardian_review_id(); @@ -628,74 +598,67 @@ impl Session { ), _ => None, }; - let action = match action.into_guardian_request(exec_command_cwd_convention) { - Ok(action) => action, - Err(err) => { - tracing::error!(%err, "failed to build automatic approval action"); - return ReviewDecision::denied( - "automatic approval review could not prepare the action", - ); - } - }; + let action = crate::guardian::ReviewAction::from_approval_action( + action, + exec_command_cwd_convention, + ); - if let Some(cancellation_token) = &ctx.cancellation_token { - let review = spawn_approval_request_review( + let reasons = ApprovalRequestReasons { + approval: ctx.approval_reason.clone(), + retry: ctx.retry_reason.clone(), + }; + let options = GuardianReviewOptions { + require_guardian: ctx.strict_auto_review, + plugin_attribution_override: None, + approval_request_source: GuardianApprovalRequestSource::MainTurn, + external_cancel: ctx.cancellation_token.clone(), + require_synchronous_review: false, + }; + if ctx.cancellation_token.is_some() { + spawn_approval_decision( Arc::clone(self), - ctx.review_context.clone(), + context, review_id, action, - ApprovalRequestReasons { - approval: ctx.approval_reason.clone(), - retry: ctx.retry_reason.clone(), - }, - GuardianReviewOptions { - plugin_attribution_override: None, - approval_request_source: GuardianApprovalRequestSource::MainTurn, - external_cancel: Some(cancellation_token.clone()), - require_synchronous_review: false, - }, - ); - review.await.unwrap_or_else(|_| { - ReviewDecision::denied("automatic approval review could not complete") + reasons, + options, + ) + .await + .unwrap_or_else(|_| { + Some(ReviewDecision::denied( + "automatic approval review could not complete", + )) }) } else if is_network_approval { let review_cancel = CancellationToken::new(); let review_cancel_guard = review_cancel.clone().drop_guard(); - let review_session = Arc::clone(self); - let review_context = ctx.review_context.clone(); - let retry_reason = ctx.retry_reason.clone(); - let review = tokio::spawn(async move { - review_approval_request_with_cancel( - &review_session, - review_context, - review_id, - action, - retry_reason, - GuardianReviewOptions { - plugin_attribution_override: None, - approval_request_source: GuardianApprovalRequestSource::MainTurn, - external_cancel: Some(review_cancel), - require_synchronous_review: false, - }, - ) - .await - }); + let review = tokio::spawn(decide_approval( + Arc::clone(self), + context, + review_id, + action, + reasons, + GuardianReviewOptions { + external_cancel: Some(review_cancel), + ..options + }, + )); let decision = review.await.unwrap_or_else(|err| { warn!("network Guardian review task failed: {err}"); - ReviewDecision::denied("automatic approval review could not complete") + Some(ReviewDecision::denied( + "automatic approval review could not complete", + )) }); drop(review_cancel_guard.disarm()); decision } else { - review_approval_request( - self, - ctx.review_context.clone(), + decide_approval( + Arc::clone(self), + context, review_id, action, - ApprovalRequestReasons { - approval: ctx.approval_reason.clone(), - retry: ctx.retry_reason.clone(), - }, + reasons, + options, ) .await } diff --git a/codex-rs/core/src/tools/approvals_tests.rs b/codex-rs/core/src/tools/approvals_tests.rs index 21e1cf8ed1..4ecd7460bf 100644 --- a/codex-rs/core/src/tools/approvals_tests.rs +++ b/codex-rs/core/src/tools/approvals_tests.rs @@ -76,6 +76,109 @@ fn approval_resolution_uses_acting_model_timeout_instructions() { } } +#[cfg(unix)] +#[test_case::test_case(ApprovalsReviewer::User, codex_extension_api::ApprovalDecision::AskUser; "manual prompt")] +#[test_case::test_case(ApprovalsReviewer::AutoReview, codex_extension_api::ApprovalDecision::Allow; "cached allow")] +#[tokio::test] +async fn non_utf8_cwd_preserves_approval_routing( + reviewer: ApprovalsReviewer, + decision: codex_extension_api::ApprovalDecision, +) -> anyhow::Result<()> { + use anyhow::Context; + use codex_extension_api::ApprovalDecision; + use std::os::unix::ffi::OsStringExt; + + struct Contributor { + cwd: codex_utils_path_uri::LegacyAppPathString, + decision: ApprovalDecision, + } + + impl codex_extension_api::ApprovalReviewContributor for Contributor { + fn decide<'a>( + &'a self, + input: &'a codex_extension_api::ApprovalDecisionInput<'_>, + ) -> codex_extension_api::ExtensionFuture<'a, Option> { + Box::pin(async move { + assert_eq!(input.action["cwd"], serde_json::json!(self.cwd)); + Some(self.decision.clone()) + }) + } + } + + let cwd = PathUri::from_abs_path(&AbsolutePathBuf::try_from(PathBuf::from( + std::ffi::OsString::from_vec(b"/tmp/non-utf8-\xe9".to_vec()), + ))?); + let (mut session, turn, events) = make_session_and_context_with_rx().await; + let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new(); + extensions.approval_review_contributor(Arc::new(Contributor { + cwd: codex_utils_path_uri::LegacyAppPathString::from_path_uri(&cwd, PathConvention::Posix)?, + decision, + })); + Arc::get_mut(&mut session) + .context("session is uniquely owned")? + .services + .extensions = Arc::new(extensions.build()); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + let mut review_context = GuardianReviewContext::from(&turn); + review_context.approval_policy = AskForApproval::OnRequest; + review_context.approvals_reviewer = reviewer; + let context = ApprovalContext { + review_context, + cancellation_token: None, + call_id: "non-utf8-cwd".to_string(), + tool_name: ToolName::plain("exec_command"), + strict_auto_review: false, + approval_reason: None, + retry_reason: None, + network_approval_context: None, + }; + let action = ApprovalAction::ExecCommand { + id: context.call_id.clone(), + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + command: vec!["npm".to_string(), "install".to_string()], + hook_command: "npm install".to_string(), + cwd: cwd.clone(), + sandbox_permissions: if reviewer == ApprovalsReviewer::User { + SandboxPermissions::RequireEscalated + } else { + SandboxPermissions::UseDefault + }, + additional_permissions: None, + justification: None, + tty: false, + proposed_execpolicy_amendment: None, + }; + let approval = session.request_reviewer_approval(action, &context); + tokio::pin!(approval); + let expected = if reviewer == ApprovalsReviewer::User { + tokio::select! { + resolution = &mut approval => panic!("expected a user prompt, got {resolution:?}"), + event = events.recv() => { + let codex_protocol::protocol::EventMsg::ExecApprovalRequest(request) = + event.context("receive user prompt")?.msg + else { + panic!("expected a command approval prompt"); + }; + assert_eq!(request.cwd, codex_utils_path_uri::LegacyAppPathString::from(cwd)); + assert_eq!(request.command, vec!["npm", "install"]); + session.notify_approval(&request.call_id, ReviewDecision::Approved).await; + } + } + ApprovalResolution { + decision: ReviewDecision::Approved, + source: ApprovalResolutionSource::User, + } + } else { + ApprovalResolution { + decision: ReviewDecision::Approved, + source: ApprovalResolutionSource::Guardian, + } + }; + assert_eq!(approval.await, expected); + assert!(events.try_recv().is_err()); + Ok(()) +} + #[tokio::test] async fn explicit_mcp_reviewer_override_takes_precedence_over_action_context() { let (session, turn, events) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 75575a61bb..e529144589 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -7,9 +7,7 @@ use anyhow::Result; use codex_config::test_support::CloudConfigBundleFixture; use codex_core::config::Constrained; use codex_extension_api::ApprovalReviewContributor; -use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionFuture; -use codex_extension_api::ExtensionMetrics; use codex_extension_api::ExtensionRegistryBuilder; use codex_features::Feature; use codex_login::CodexAuth; @@ -29,7 +27,6 @@ use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; -use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -65,17 +62,14 @@ use wiremock::matchers::path; struct ApprovedReviewContributor; impl ApprovalReviewContributor for ApprovedReviewContributor { - fn fast_decision<'a>( + fn decide<'a>( &'a self, - _session_store: &'a ExtensionData, - _thread_store: &'a ExtensionData, - prompt: &'a str, - extension_metrics: Option>, - ) -> ExtensionFuture<'a, Option> { + input: &'a codex_extension_api::ApprovalDecisionInput<'_>, + ) -> ExtensionFuture<'a, Option> { Box::pin(async move { - assert!(extension_metrics.is_some()); - assert!(prompt.contains("\"tool\":\"request_permissions\"")); - Some(ReviewDecision::Approved) + assert!(input.metrics.is_some()); + assert_eq!(input.action["tool"], "request_permissions"); + Some(codex_extension_api::ApprovalDecision::Allow) }) } } @@ -83,16 +77,13 @@ impl ApprovalReviewContributor for ApprovedReviewContributor { struct EscalationApprovingReviewContributor; impl ApprovalReviewContributor for EscalationApprovingReviewContributor { - fn fast_decision<'a>( + fn decide<'a>( &'a self, - _session_store: &'a ExtensionData, - _thread_store: &'a ExtensionData, - prompt: &'a str, - _extension_metrics: Option>, - ) -> ExtensionFuture<'a, Option> { + input: &'a codex_extension_api::ApprovalDecisionInput<'_>, + ) -> ExtensionFuture<'a, Option> { Box::pin(async move { - assert!(prompt.contains("\"sandbox_permissions\":\"require_escalated\"")); - Some(ReviewDecision::Approved) + assert_eq!(input.action["sandbox_permissions"], "require_escalated"); + Some(codex_extension_api::ApprovalDecision::Allow) }) } } diff --git a/codex-rs/core/tests/suite/context_annotations.rs b/codex-rs/core/tests/suite/context_annotations.rs index cd7c1eefe1..69b7d32e68 100644 --- a/codex-rs/core/tests/suite/context_annotations.rs +++ b/codex-rs/core/tests/suite/context_annotations.rs @@ -75,6 +75,7 @@ async fn first_request_item_types_roles_and_content_annotations() -> Result<()> test.codex .submit(Op::ApproveGuardianDeniedAction { event: GuardianAssessmentEvent { + review_reason: None, id: "guardian-review".to_string(), target_item_id: None, plugin_id: None, diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index 63a7aabd75..538def3138 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -1561,14 +1561,16 @@ async fn guardian_timeout_rejects_tool_call_with_acting_model_instructions( struct TimedOutReviewContributor; impl codex_extension_api::ApprovalReviewContributor for TimedOutReviewContributor { - fn fast_decision<'a>( + fn decide<'a>( &'a self, - _session_store: &'a codex_extension_api::ExtensionData, - _thread_store: &'a codex_extension_api::ExtensionData, - _prompt: &'a str, - _extension_metrics: Option>, - ) -> codex_extension_api::ExtensionFuture<'a, Option> { - Box::pin(async { Some(ReviewDecision::TimedOut) }) + _input: &'a codex_extension_api::ApprovalDecisionInput<'_>, + ) -> codex_extension_api::ExtensionFuture<'a, Option> + { + Box::pin(async { + Some(codex_extension_api::ApprovalDecision::Reviewed( + ReviewDecision::TimedOut, + )) + }) } } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/approval.rs b/codex-rs/ext/guardian-v2/src/async_scorer/approval.rs index c5d9ed717c..d783468db3 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/approval.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/approval.rs @@ -5,6 +5,7 @@ use super::authorization::ScoreAuthorization; use super::config::GuardianV2Config; use super::coverage::GuardianPolicy; use super::extension::GuardianV2ScoreProgress; +use super::extension::encrypted_parent_compaction; use super::extension::requires_sync_for_compaction; use super::metrics::TOOL_CALL_LAG_METRIC; use super::metrics::record_fast_decision; @@ -139,7 +140,10 @@ async fn cached_evidence( .get::() .ok_or(GuardianReviewReason::MissingScore)?; let history = thread.conversation_history_snapshot().await; - if requires_sync_for_compaction(config, history.as_ref(), &sampler) { + if requires_sync_for_compaction(config, history.as_ref(), &sampler) + || encrypted_parent_compaction(history.items(), config.max_parent_compaction_tokens) + .is_err() + { record_fast_decision(metrics, "deferred", "incompatible_compaction"); return Err(GuardianReviewReason::IncompatibleCompaction); } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs index a12c2018b7..34bb6e5b6a 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -987,7 +987,7 @@ impl GuardianV2Extension { } #[derive(Debug, Eq, PartialEq)] -enum ParentCompactionError { +pub(super) enum ParentCompactionError { Serialization, Oversized, Unusable, @@ -1010,7 +1010,7 @@ pub(super) fn requires_sync_for_compaction( // An unusable latest compaction must never fall back to an older one. Missing // encrypted content is rejected here; only legacy callers may omit that checkpoint. -fn encrypted_parent_compaction<'a>( +pub(super) fn encrypted_parent_compaction<'a>( items: impl Iterator, max_parent_compaction_tokens: usize, ) -> Result, ParentCompactionError> { diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index b05c45ce49..3325a90539 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -204,6 +204,10 @@ pub enum GuardianReviewReason { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct GuardianAssessmentEvent { + /// Request-scoped trigger; absent in events recorded by older clients. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub review_reason: Option, /// Stable identifier for this guardian review lifecycle. pub id: String, /// Thread item being reviewed, when the review maps to a concrete item. diff --git a/codex-rs/tui/src/auto_review_denials.rs b/codex-rs/tui/src/auto_review_denials.rs index c2f7ebbe5d..d6c5ee4229 100644 --- a/codex-rs/tui/src/auto_review_denials.rs +++ b/codex-rs/tui/src/auto_review_denials.rs @@ -94,6 +94,7 @@ mod tests { fn denied_event(id: usize) -> GuardianAssessmentEvent { GuardianAssessmentEvent { + review_reason: None, id: format!("review-{id}"), target_item_id: None, plugin_id: None, diff --git a/codex-rs/tui/src/chatwidget/protocol_requests.rs b/codex-rs/tui/src/chatwidget/protocol_requests.rs index 0d7ef630be..89f78ea6e2 100644 --- a/codex-rs/tui/src/chatwidget/protocol_requests.rs +++ b/codex-rs/tui/src/chatwidget/protocol_requests.rs @@ -95,6 +95,7 @@ impl ChatWidget { }; self.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id, target_item_id: None, plugin_id: None, diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index d0347860d0..1cac151689 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -3,6 +3,7 @@ use pretty_assertions::assert_eq; fn auto_review_denial_event() -> GuardianAssessmentEvent { GuardianAssessmentEvent { + review_reason: None, id: "auto-review-recent-1".into(), target_item_id: Some("target-auto-review-recent-1".into()), plugin_id: None, @@ -31,6 +32,7 @@ fn guardian_command_event( ) -> GuardianAssessmentEvent { let terminal = status != GuardianAssessmentStatus::InProgress; GuardianAssessmentEvent { + review_reason: None, id: id.to_string(), target_item_id: Some(format!("{id}-target")), plugin_id: None, @@ -244,6 +246,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { }; chat.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id: "guardian-1".into(), target_item_id: Some("guardian-target-1".into()), plugin_id: None, @@ -260,6 +263,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { }); chat.on_warning("Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint."); chat.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id: "guardian-1".into(), target_item_id: Some("guardian-target-1".into()), plugin_id: None, @@ -313,6 +317,7 @@ async fn guardian_approved_exec_is_hidden_from_history() { /*replay_kind*/ None, ); chat.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id: "thread:child-thread:guardian-1".into(), target_item_id: Some("guardian-approved-target".into()), plugin_id: None, @@ -370,6 +375,7 @@ async fn guardian_approved_request_permissions_clears_status_without_history() { }; chat.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id: "guardian-request-permissions".into(), target_item_id: None, plugin_id: None, @@ -396,6 +402,7 @@ async fn guardian_approved_request_permissions_clears_status_without_history() { ); chat.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id: "guardian-request-permissions".into(), target_item_id: None, plugin_id: None, @@ -448,6 +455,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { }; chat.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id: "guardian-1".into(), target_item_id: Some("guardian-target-1".into()), plugin_id: None, @@ -464,6 +472,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { }); chat.on_warning("Automatic approval review timed out while evaluating the requested approval."); chat.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id: "guardian-1".into(), target_item_id: Some("guardian-target-1".into()), plugin_id: None, diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 95a89273ef..c852182d13 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -534,6 +534,7 @@ async fn mcp_startup_complete_preserves_review_status() { ); chat.on_guardian_assessment(GuardianAssessmentEvent { + review_reason: None, id: "guardian-1".to_string(), target_item_id: Some("guardian-target-1".to_string()), plugin_id: None,