From 791c0d92c061e8037638312c15151dbfc0a2b831 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 14:39:21 -0700 Subject: [PATCH 01/26] initial draft --- .../schema/json/ClientRequest.json | 19 +++ .../codex_app_server_protocol.schemas.json | 19 +++ .../codex_app_server_protocol.v2.schemas.json | 19 +++ .../RawResponseItemCompletedNotification.json | 19 +++ .../schema/json/v2/ThreadResumeParams.json | 19 +++ .../typescript/ApprovalSourceMetadata.ts | 5 + .../schema/typescript/ResponseItemMetadata.ts | 3 +- .../schema/typescript/index.ts | 1 + codex-rs/core/src/codex.rs | 47 +++++- codex-rs/core/src/codex_tests.rs | 140 +++++++++++++++++- codex-rs/core/src/state/turn.rs | 18 +++ .../tools/runtimes/shell/unix_escalation.rs | 2 + codex-rs/protocol/src/models.rs | 17 +++ 13 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/ApprovalSourceMetadata.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a6fe45cc53..ca6cd5cc67 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,6 +5,15 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApprovalSourceMetadata": { + "enum": [ + "guardian", + "user", + "policy", + "unknown" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -1992,6 +2001,16 @@ }, "ResponseItemMetadata": { "properties": { + "approval_source": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalSourceMetadata" + }, + { + "type": "null" + } + ] + }, "is_tool_call_escalated": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 47faa5b18d..303b3e3663 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5272,6 +5272,15 @@ "AppToolsConfig": { "type": "object" }, + "ApprovalSourceMetadata": { + "enum": [ + "guardian", + "user", + "policy", + "unknown" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -10659,6 +10668,16 @@ }, "ResponseItemMetadata": { "properties": { + "approval_source": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalSourceMetadata" + }, + { + "type": "null" + } + ] + }, "is_tool_call_escalated": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 87b70f1f93..1c7b913fae 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -532,6 +532,15 @@ "AppToolsConfig": { "type": "object" }, + "ApprovalSourceMetadata": { + "enum": [ + "guardian", + "user", + "policy", + "unknown" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -7447,6 +7456,16 @@ }, "ResponseItemMetadata": { "properties": { + "approval_source": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalSourceMetadata" + }, + { + "type": "null" + } + ] + }, "is_tool_call_escalated": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 1cbc1f6337..327a15e708 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1,6 +1,15 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "ApprovalSourceMetadata": { + "enum": [ + "guardian", + "user", + "policy", + "unknown" + ], + "type": "string" + }, "ContentItem": { "oneOf": [ { @@ -840,6 +849,16 @@ }, "ResponseItemMetadata": { "properties": { + "approval_source": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalSourceMetadata" + }, + { + "type": "null" + } + ] + }, "is_tool_call_escalated": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index a397730faa..0925fa67b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,15 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "ApprovalSourceMetadata": { + "enum": [ + "guardian", + "user", + "policy", + "unknown" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -906,6 +915,16 @@ }, "ResponseItemMetadata": { "properties": { + "approval_source": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalSourceMetadata" + }, + { + "type": "null" + } + ] + }, "is_tool_call_escalated": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/typescript/ApprovalSourceMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/ApprovalSourceMetadata.ts new file mode 100644 index 0000000000..37da210732 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ApprovalSourceMetadata.ts @@ -0,0 +1,5 @@ +// 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. + +export type ApprovalSourceMetadata = "guardian" | "user" | "policy" | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts index deb3c845bd..52a7bc1185 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts @@ -1,6 +1,7 @@ // 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 { ApprovalSourceMetadata } from "./ApprovalSourceMetadata"; import type { ReviewDecisionMetadata } from "./ReviewDecisionMetadata"; import type { SandboxPolicyMetadata } from "./SandboxPolicyMetadata"; import type { UserMessageType } from "./UserMessageType"; @@ -9,4 +10,4 @@ export type ResponseItemMetadata = { user_message_type?: UserMessageType, /** * Client-visible UUID generated by Codex for this item. */ -uuid?: string, sandbox_policy?: SandboxPolicyMetadata, is_tool_call_escalated?: boolean, review_decision?: ReviewDecisionMetadata, }; +uuid?: string, sandbox_policy?: SandboxPolicyMetadata, is_tool_call_escalated?: boolean, review_decision?: ReviewDecisionMetadata, approval_source?: ApprovalSourceMetadata, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index c3aa4a74aa..5db771c029 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -3,6 +3,7 @@ export type { AbsolutePathBuf } from "./AbsolutePathBuf"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; +export type { ApprovalSourceMetadata } from "./ApprovalSourceMetadata"; export type { AuthMode } from "./AuthMode"; export type { ClientInfo } from "./ClientInfo"; export type { ClientNotification } from "./ClientNotification"; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8e26c0c88a..09eeb6fd26 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -88,8 +88,10 @@ use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::ApprovalSourceMetadata; use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ReviewDecisionMetadata; use codex_protocol::models::UserMessageType; use codex_protocol::models::format_allow_prefixes; use codex_protocol::openai_models::ModelInfo; @@ -213,6 +215,7 @@ use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; use crate::hook_runtime::run_pending_session_start_hooks; use crate::hook_runtime::run_user_prompt_submit_hooks; +use crate::guardian::routes_approval_to_guardian; use crate::instructions::UserInstructions; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::McpManager; @@ -1079,6 +1082,35 @@ fn stamp_message_metadata_on_response_item( } } +fn review_decision_to_metadata(decision: &ReviewDecision) -> ReviewDecisionMetadata { + match decision { + ReviewDecision::Approved => ReviewDecisionMetadata::Approved, + ReviewDecision::ApprovedExecpolicyAmendment { .. } => { + ReviewDecisionMetadata::ApprovedWithAmendment + } + ReviewDecision::ApprovedForSession => ReviewDecisionMetadata::ApprovedForSession, + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => match network_policy_amendment.action { + codex_protocol::protocol::NetworkPolicyRuleAction::Allow => { + ReviewDecisionMetadata::ApprovedWithNetworkPolicyAllow + } + codex_protocol::protocol::NetworkPolicyRuleAction::Deny => { + ReviewDecisionMetadata::DeniedWithNetworkPolicyDeny + } + }, + ReviewDecision::Denied => ReviewDecisionMetadata::Denied, + ReviewDecision::Abort => ReviewDecisionMetadata::Abort, + } +} + +fn approval_source_for_turn(turn_context: &TurnContext) -> ApprovalSourceMetadata { + if routes_approval_to_guardian(turn_context) { + ApprovalSourceMetadata::Guardian + } else { + ApprovalSourceMetadata::User + } +} fn response_item_tool_call_id(item: &ResponseItem) -> Option<&str> { match item { ResponseItem::LocalShellCall { @@ -1162,10 +1194,12 @@ fn stamp_tool_approval_metadata_with_snapshot( Some(outcome) => { metadata.is_tool_call_escalated = Some(true); metadata.review_decision = outcome.review_decision; + metadata.approval_source = Some(outcome.approval_source); } None if !has_pending_approval => { metadata.is_tool_call_escalated = Some(false); metadata.review_decision = None; + metadata.approval_source = None; } None => { return stamp_tool_metadata_on_response_item(response_item, metadata); @@ -3030,6 +3064,7 @@ impl Session { effective_approval_id.clone(), PendingApprovalMetadata { call_id: call_id.clone(), + approval_source: approval_source_for_turn(turn_context), }, ); ts.insert_pending_approval(effective_approval_id.clone(), tx_approve) @@ -3101,6 +3136,7 @@ impl Session { approval_id.clone(), PendingApprovalMetadata { call_id: call_id.clone(), + approval_source: approval_source_for_turn(turn_context), }, ); ts.insert_pending_approval(approval_id.clone(), tx_approve) @@ -3398,16 +3434,25 @@ impl Session { .remove_pending_approval_call_id(approval_id) .unwrap_or_else(|| PendingApprovalMetadata { call_id: approval_id.to_string(), + approval_source: ApprovalSourceMetadata::Unknown, }); drop(ts); drop(active); self.record_call_approval_outcome( pending_approval.call_id, - ApprovalOutcomeMetadata::reviewed(decision), + ApprovalOutcomeMetadata::reviewed_with_source( + decision, + pending_approval.approval_source, + ), ) .await; } + pub async fn record_policy_outcome(&self, call_id: &str) { + self.record_call_approval_outcome(call_id.to_string(), ApprovalOutcomeMetadata::policy()) + .await; + } + pub(crate) async fn record_call_approval_outcome( &self, call_id: String, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 0533a41a96..0a834e40ca 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4438,6 +4438,29 @@ fn review_decision_metadata_mapping_is_stable() { ); } +#[tokio::test] +async fn approval_source_metadata_mapping_is_stable() { + let (_session, mut turn_context) = make_session_and_context().await; + + assert_eq!( + approval_source_for_turn(&turn_context), + codex_protocol::models::ApprovalSourceMetadata::User + ); + + turn_context + .approval_policy + .set(crate::protocol::AskForApproval::OnRequest) + .expect("test setup should allow updating approval policy"); + Arc::get_mut(&mut turn_context.config) + .expect("single turn config ref") + .approvals_reviewer = codex_protocol::config_types::ApprovalsReviewer::GuardianSubagent; + + assert_eq!( + approval_source_for_turn(&turn_context), + codex_protocol::models::ApprovalSourceMetadata::Guardian + ); +} + #[test] fn sandbox_policy_metadata_mapping_is_stable() { assert_eq!( @@ -4501,6 +4524,7 @@ async fn assert_next_emitted_function_call_metadata( expected_sandbox_policy: codex_protocol::models::SandboxPolicyMetadata, expected_escalated: bool, expected_review_decision: Option, + expected_approval_source: Option, ) { let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await @@ -4516,6 +4540,8 @@ async fn assert_next_emitted_function_call_metadata( .. } if metadata.is_tool_call_escalated == Some(expected_escalated) && metadata.review_decision == expected_review_decision + && metadata.approval_source + == expected_approval_source && metadata.sandbox_policy == Some(expected_sandbox_policy) ) )); @@ -4537,6 +4563,7 @@ async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enable expected_sandbox_policy, true, Some(codex_protocol::models::ReviewDecisionMetadata::Denied), + Some(codex_protocol::models::ApprovalSourceMetadata::User), ) .await; } @@ -4547,7 +4574,34 @@ async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { sess.record_response_item_and_emit_turn_item(tc.as_ref(), function_call_item("call-2")) .await; - assert_next_emitted_function_call_metadata(&rx, expected_sandbox_policy, false, None).await; + assert_next_emitted_function_call_metadata( + &rx, + expected_sandbox_policy, + false, + None, + None, + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_feature_enabled() { + let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; + + sess.record_policy_outcome("call-policy-runtime-1").await; + sess.record_response_item_and_emit_turn_item( + tc.as_ref(), + function_call_item("call-policy-runtime-1"), + ) + .await; + assert_next_emitted_function_call_metadata( + &rx, + expected_sandbox_policy, + true, + None, + Some(codex_protocol::models::ApprovalSourceMetadata::Policy), + ) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -4603,6 +4657,7 @@ async fn handle_output_item_done_stamps_tool_call_metadata_when_feature_enabled( } if call_id == "call-stream-1" && metadata.is_tool_call_escalated == Some(false) && metadata.review_decision.is_none() + && metadata.approval_source.is_none() && metadata.sandbox_policy == Some(expected_sandbox_policy.clone()) ) })); @@ -4675,6 +4730,89 @@ async fn tool_call_metadata_can_be_restamped_after_approval_outcome() { } if metadata.is_tool_call_escalated == Some(true) && metadata.review_decision == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::User) + && metadata.sandbox_policy == Some(expected_sandbox_policy) + )); +} + +#[tokio::test] +async fn tool_call_metadata_snapshot_stamps_guardian_approval_source() { + let (_session, turn_context_raw) = make_session_and_context().await; + let turn_context = Arc::new(turn_context_raw); + let expected_sandbox_policy = sandbox_policy_to_metadata(turn_context.sandbox_policy.get()); + let item = ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-guardian-1".to_string(), + metadata: None, + }; + let snapshot = ToolApprovalMetadataSnapshot { + approval_outcomes_by_call_id: HashMap::from([( + "call-guardian-1".to_string(), + crate::state::ApprovalOutcomeMetadata { + review_decision: Some(codex_protocol::models::ReviewDecisionMetadata::Denied), + approval_source: codex_protocol::models::ApprovalSourceMetadata::Guardian, + }, + )]), + pending_approval_call_ids: HashSet::new(), + }; + + let stamped = + stamp_tool_approval_metadata_with_snapshot(turn_context.as_ref(), item, Some(&snapshot)); + + assert!(matches!( + stamped, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision + == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) + && metadata.sandbox_policy == Some(expected_sandbox_policy) + )); +} + +#[tokio::test] +async fn tool_call_metadata_snapshot_stamps_policy_approval_source_without_review_decision() { + let (_session, turn_context_raw) = make_session_and_context().await; + let turn_context = Arc::new(turn_context_raw); + let expected_sandbox_policy = sandbox_policy_to_metadata(turn_context.sandbox_policy.get()); + let item = ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-policy-1".to_string(), + metadata: None, + }; + let snapshot = ToolApprovalMetadataSnapshot { + approval_outcomes_by_call_id: HashMap::from([( + "call-policy-1".to_string(), + crate::state::ApprovalOutcomeMetadata { + review_decision: None, + approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, + }, + )]), + pending_approval_call_ids: HashSet::new(), + }; + + let stamped = + stamp_tool_approval_metadata_with_snapshot(turn_context.as_ref(), item, Some(&snapshot)); + + assert!(matches!( + stamped, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision.is_none() + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::Policy) && metadata.sandbox_policy == Some(expected_sandbox_policy) )); } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index b675be074a..55f4ee1f18 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -10,6 +10,7 @@ use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::models::ApprovalSourceMetadata; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItemMetadata; use codex_protocol::models::ReviewDecisionMetadata; @@ -68,15 +69,24 @@ pub(crate) struct PendingInputItem { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingApprovalMetadata { pub(crate) call_id: String, + pub(crate) approval_source: ApprovalSourceMetadata, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ApprovalOutcomeMetadata { pub(crate) review_decision: Option, + pub(crate) approval_source: ApprovalSourceMetadata, } impl ApprovalOutcomeMetadata { pub(crate) fn reviewed(decision: &ReviewDecision) -> Self { + Self::reviewed_with_source(decision, ApprovalSourceMetadata::User) + } + + pub(crate) fn reviewed_with_source( + decision: &ReviewDecision, + approval_source: ApprovalSourceMetadata, + ) -> Self { let review_decision = match decision { ReviewDecision::Approved => ReviewDecisionMetadata::Approved, ReviewDecision::ApprovedExecpolicyAmendment { .. } => { @@ -98,6 +108,14 @@ impl ApprovalOutcomeMetadata { }; Self { review_decision: Some(review_decision), + approval_source, + } + } + + pub(crate) fn policy() -> Self { + Self { + review_decision: None, + approval_source: ApprovalSourceMetadata::Policy, } } } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index afad1da2ab..d0e6539457 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -520,12 +520,14 @@ impl CoreShellActionProvider { ) -> anyhow::Result { let action = match decision { Decision::Forbidden => { + self.session.record_policy_outcome(&self.call_id).await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } Decision::Prompt => { if execve_prompt_is_rejected_by_policy(self.approval_policy, &decision_source) .is_some() { + self.session.record_policy_outcome(&self.call_id).await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } else { match self diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index cc6c9535bb..7a98341f64 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -291,6 +291,16 @@ pub enum SandboxPolicyMetadata { FullAccess, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ApprovalSourceMetadata { + Guardian, + User, + Policy, + Unknown, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] pub struct ResponseItemMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -309,6 +319,9 @@ pub struct ResponseItemMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub review_decision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub approval_source: Option, } impl ResponseItemMetadata { @@ -318,6 +331,7 @@ impl ResponseItemMetadata { && self.sandbox_policy.is_none() && self.is_tool_call_escalated.is_none() && self.review_decision.is_none() + && self.approval_source.is_none() } pub fn merge_from(&mut self, other: Self) { @@ -336,6 +350,9 @@ impl ResponseItemMetadata { if other.review_decision.is_some() { self.review_decision = other.review_decision; } + if other.approval_source.is_some() { + self.approval_source = other.approval_source; + } } } From 63df3116f05e241a01a784f14ca2a670dd55b878 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 15:43:11 -0700 Subject: [PATCH 02/26] Trigger CI From aab34f8037e921bf9c320be632cb81b42c0a3012 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 16:49:25 -0700 Subject: [PATCH 03/26] guardian patch and test update --- codex-rs/core/src/codex.rs | 21 ++++-- codex-rs/core/src/codex_tests.rs | 72 ++++++++++++++++++- codex-rs/core/src/mcp_tool_call.rs | 3 + .../core/src/tools/runtimes/apply_patch.rs | 11 ++- codex-rs/core/src/tools/runtimes/shell.rs | 13 +++- .../tools/runtimes/shell/unix_escalation.rs | 11 ++- .../core/src/tools/runtimes/unified_exec.rs | 13 +++- 7 files changed, 130 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 09eeb6fd26..8560db415b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3438,12 +3438,23 @@ impl Session { }); drop(ts); drop(active); + self.record_direct_approval_outcome( + &pending_approval.call_id, + decision, + pending_approval.approval_source, + ) + .await; + } + + pub async fn record_direct_approval_outcome( + &self, + call_id: &str, + decision: &ReviewDecision, + approval_source: ApprovalSourceMetadata, + ) { self.record_call_approval_outcome( - pending_approval.call_id, - ApprovalOutcomeMetadata::reviewed_with_source( - decision, - pending_approval.approval_source, - ), + call_id.to_string(), + ApprovalOutcomeMetadata::reviewed_with_source(decision, approval_source), ) .await; } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 0a834e40ca..be8ca88da2 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4584,6 +4584,71 @@ async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() { + let (mut sess, tc, rx) = make_session_and_context_with_rx().await; + let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); + Arc::get_mut(&mut sess) + .expect("session should be uniquely owned in this test") + .features + .enable(crate::features::Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); + + sess.spawn_task( + Arc::clone(&tc), + vec![UserInput::Text { + text: "start".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + while rx.try_recv().is_ok() {} + + sess.record_direct_approval_outcome( + "call-guardian-runtime-1", + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::Guardian, + ) + .await; + sess.record_response_item_and_emit_turn_item( + tc.as_ref(), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-guardian-runtime-1".to_string(), + metadata: None, + }, + ) + .await; + + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!( + event.msg, + EventMsg::RawResponseItem(ref ev) + if matches!( + &ev.item, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision + == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) + && metadata.sandbox_policy == Some(expected_sandbox_policy) + ) + )); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_feature_enabled() { let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; @@ -4698,9 +4763,10 @@ async fn tool_call_metadata_can_be_restamped_after_approval_outcome() { }, ) .await; - sess.record_call_approval_outcome( - "call-restamp-1".to_string(), - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::Denied), + sess.record_direct_approval_outcome( + "call-restamp-1", + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::User, ) .await; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 06d801cbac..8b235d71a5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -34,6 +34,7 @@ use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; use crate::state_db; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::ApprovalSourceMetadata; use codex_protocol::openai_models::InputModality; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; @@ -532,6 +533,8 @@ async fn maybe_request_mcp_tool_approval( monitor_reason.clone(), ) .await; + sess.record_direct_approval_outcome(call_id, &decision, ApprovalSourceMetadata::Guardian) + .await; let decision = mcp_tool_approval_decision_from_guardian(decision); apply_mcp_tool_approval_decision( sess, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 105b451193..5a1a8d2888 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -23,6 +23,7 @@ use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::with_cached_approval; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_protocol::models::ApprovalSourceMetadata; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::FileChange; @@ -140,7 +141,15 @@ impl Approvable for ApplyPatchRuntime { Box::pin(async move { if routes_approval_to_guardian(turn) { let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); - return review_approval_request(session, turn, action, retry_reason).await; + let decision = review_approval_request(session, turn, action, retry_reason).await; + session + .record_direct_approval_outcome( + &call_id, + &decision, + ApprovalSourceMetadata::Guardian, + ) + .await; + return decision; } if req.permissions_preapproved && retry_reason.is_none() { return ReviewDecision::Approved; diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index d7b07ed0d4..4557cd88e9 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -35,6 +35,7 @@ use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::sandbox_override_for_first_attempt; use crate::tools::sandboxing::with_cached_approval; use codex_network_proxy::NetworkProxy; +use codex_protocol::models::ApprovalSourceMetadata; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; use futures::future::BoxFuture; @@ -153,11 +154,11 @@ impl Approvable for ShellRuntime { let call_id = ctx.call_id.to_string(); Box::pin(async move { if routes_approval_to_guardian(turn) { - return review_approval_request( + let decision = review_approval_request( session, turn, GuardianApprovalRequest::Shell { - id: call_id, + id: call_id.clone(), command, cwd, sandbox_permissions: req.sandbox_permissions, @@ -167,6 +168,14 @@ impl Approvable for ShellRuntime { retry_reason, ) .await; + session + .record_direct_approval_outcome( + &call_id, + &decision, + ApprovalSourceMetadata::Guardian, + ) + .await; + return decision; } with_cached_approval(&session.services, "shell", keys, move || async move { let available_decisions = None; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index d0e6539457..e18be56d75 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -25,6 +25,7 @@ use codex_execpolicy::MatchOptions; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::ApprovalSourceMetadata; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; @@ -424,7 +425,7 @@ impl CoreShellActionProvider { Ok(stopwatch .pause_for(async move { if routes_approval_to_guardian(&turn) { - return review_approval_request( + let decision = review_approval_request( &session, &turn, GuardianApprovalRequest::Execve { @@ -438,6 +439,14 @@ impl CoreShellActionProvider { /*retry_reason*/ None, ) .await; + session + .record_direct_approval_outcome( + &call_id, + &decision, + ApprovalSourceMetadata::Guardian, + ) + .await; + return decision; } let available_decisions = vec![ Some(ReviewDecision::Approved), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 22fc732f60..b444938cd4 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -38,6 +38,7 @@ use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; use codex_network_proxy::NetworkProxy; +use codex_protocol::models::ApprovalSourceMetadata; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; use futures::future::BoxFuture; @@ -121,11 +122,11 @@ impl Approvable for UnifiedExecRuntime<'_> { let reason = retry_reason.clone().or_else(|| req.justification.clone()); Box::pin(async move { if routes_approval_to_guardian(turn) { - return review_approval_request( + let decision = review_approval_request( session, turn, GuardianApprovalRequest::ExecCommand { - id: call_id, + id: call_id.clone(), command, cwd, sandbox_permissions: req.sandbox_permissions, @@ -136,6 +137,14 @@ impl Approvable for UnifiedExecRuntime<'_> { retry_reason, ) .await; + session + .record_direct_approval_outcome( + &call_id, + &decision, + ApprovalSourceMetadata::Guardian, + ) + .await; + return decision; } with_cached_approval(&session.services, "unified_exec", keys, || async move { let available_decisions = None; From 4a02049e49a4bf8959a7d1bbf89420ce0d4f5d22 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 17:43:46 -0700 Subject: [PATCH 04/26] refactor --- codex-rs/core/src/codex.rs | 42 +------- codex-rs/core/src/codex_tests.rs | 98 +++++++++++++------ codex-rs/core/src/mcp_tool_call.rs | 8 +- codex-rs/core/src/state/turn.rs | 6 +- .../core/src/tools/runtimes/apply_patch.rs | 11 ++- codex-rs/core/src/tools/runtimes/shell.rs | 11 ++- .../tools/runtimes/shell/unix_escalation.rs | 25 +++-- .../core/src/tools/runtimes/unified_exec.rs | 11 ++- 8 files changed, 116 insertions(+), 96 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8560db415b..ea12c23ba4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -91,7 +91,6 @@ use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ApprovalSourceMetadata; use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; -use codex_protocol::models::ReviewDecisionMetadata; use codex_protocol::models::UserMessageType; use codex_protocol::models::format_allow_prefixes; use codex_protocol::openai_models::ModelInfo; @@ -1082,28 +1081,6 @@ fn stamp_message_metadata_on_response_item( } } -fn review_decision_to_metadata(decision: &ReviewDecision) -> ReviewDecisionMetadata { - match decision { - ReviewDecision::Approved => ReviewDecisionMetadata::Approved, - ReviewDecision::ApprovedExecpolicyAmendment { .. } => { - ReviewDecisionMetadata::ApprovedWithAmendment - } - ReviewDecision::ApprovedForSession => ReviewDecisionMetadata::ApprovedForSession, - ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment, - } => match network_policy_amendment.action { - codex_protocol::protocol::NetworkPolicyRuleAction::Allow => { - ReviewDecisionMetadata::ApprovedWithNetworkPolicyAllow - } - codex_protocol::protocol::NetworkPolicyRuleAction::Deny => { - ReviewDecisionMetadata::DeniedWithNetworkPolicyDeny - } - }, - ReviewDecision::Denied => ReviewDecisionMetadata::Denied, - ReviewDecision::Abort => ReviewDecisionMetadata::Abort, - } -} - fn approval_source_for_turn(turn_context: &TurnContext) -> ApprovalSourceMetadata { if routes_approval_to_guardian(turn_context) { ApprovalSourceMetadata::Guardian @@ -3438,23 +3415,9 @@ impl Session { }); drop(ts); drop(active); - self.record_direct_approval_outcome( - &pending_approval.call_id, - decision, - pending_approval.approval_source, - ) - .await; - } - - pub async fn record_direct_approval_outcome( - &self, - call_id: &str, - decision: &ReviewDecision, - approval_source: ApprovalSourceMetadata, - ) { self.record_call_approval_outcome( - call_id.to_string(), - ApprovalOutcomeMetadata::reviewed_with_source(decision, approval_source), + pending_approval.call_id, + ApprovalOutcomeMetadata::reviewed(decision, pending_approval.approval_source), ) .await; } @@ -3463,7 +3426,6 @@ impl Session { self.record_call_approval_outcome(call_id.to_string(), ApprovalOutcomeMetadata::policy()) .await; } - pub(crate) async fn record_call_approval_outcome( &self, call_id: String, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index be8ca88da2..de11b6af57 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4392,47 +4392,72 @@ async fn task_finish_emits_prompt_queued_metadata_for_injected_user_input_when_f #[test] fn review_decision_metadata_mapping_is_stable() { assert_eq!( - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::Approved).review_decision, + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Approved, + codex_protocol::models::ApprovalSourceMetadata::User, + ) + .review_decision, Some(codex_protocol::models::ReviewDecisionMetadata::Approved) ); assert_eq!( - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::Denied).review_decision, + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::User, + ) + .review_decision, Some(codex_protocol::models::ReviewDecisionMetadata::Denied) ); assert_eq!( - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::Abort).review_decision, + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Abort, + codex_protocol::models::ApprovalSourceMetadata::User, + ) + .review_decision, Some(codex_protocol::models::ReviewDecisionMetadata::Abort) ); assert_eq!( - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::ApprovedForSession).review_decision, + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::ApprovedForSession, + codex_protocol::models::ApprovalSourceMetadata::User, + ) + .review_decision, Some(codex_protocol::models::ReviewDecisionMetadata::ApprovedForSession) ); assert_eq!( - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: codex_protocol::approvals::ExecPolicyAmendment::new( - vec!["echo".to_string(), "hi".to_string()], - ), - }) + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: codex_protocol::approvals::ExecPolicyAmendment::new( + vec!["echo".to_string(), "hi".to_string()], + ), + }, + codex_protocol::models::ApprovalSourceMetadata::User, + ) .review_decision, Some(codex_protocol::models::ReviewDecisionMetadata::ApprovedWithAmendment) ); assert_eq!( - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { - host: "example.com".to_string(), - action: codex_protocol::protocol::NetworkPolicyRuleAction::Allow, + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { + host: "example.com".to_string(), + action: codex_protocol::protocol::NetworkPolicyRuleAction::Allow, + }, }, - }) + codex_protocol::models::ApprovalSourceMetadata::User, + ) .review_decision, Some(codex_protocol::models::ReviewDecisionMetadata::ApprovedWithNetworkPolicyAllow) ); assert_eq!( - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { - host: "example.com".to_string(), - action: codex_protocol::protocol::NetworkPolicyRuleAction::Deny, + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { + host: "example.com".to_string(), + action: codex_protocol::protocol::NetworkPolicyRuleAction::Deny, + }, }, - }) + codex_protocol::models::ApprovalSourceMetadata::User, + ) .review_decision, Some(codex_protocol::models::ReviewDecisionMetadata::DeniedWithNetworkPolicyDeny) ); @@ -4553,7 +4578,10 @@ async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enable sess.record_call_approval_outcome( "call-1".to_string(), - ApprovalOutcomeMetadata::reviewed(&ReviewDecision::Denied), + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::User, + ), ) .await; sess.record_response_item_and_emit_turn_item(tc.as_ref(), function_call_item("call-1")) @@ -4608,10 +4636,12 @@ async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() .await; while rx.try_recv().is_ok() {} - sess.record_direct_approval_outcome( - "call-guardian-runtime-1", - &ReviewDecision::Denied, - codex_protocol::models::ApprovalSourceMetadata::Guardian, + sess.record_call_approval_outcome( + "call-guardian-runtime-1".to_string(), + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::Guardian, + ), ) .await; sess.record_response_item_and_emit_turn_item( @@ -4653,7 +4683,11 @@ async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_feature_enabled() { let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; - sess.record_policy_outcome("call-policy-runtime-1").await; + sess.record_call_approval_outcome( + "call-policy-runtime-1".to_string(), + ApprovalOutcomeMetadata::policy(), + ) + .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), function_call_item("call-policy-runtime-1"), @@ -4763,10 +4797,12 @@ async fn tool_call_metadata_can_be_restamped_after_approval_outcome() { }, ) .await; - sess.record_direct_approval_outcome( - "call-restamp-1", - &ReviewDecision::Denied, - codex_protocol::models::ApprovalSourceMetadata::User, + sess.record_call_approval_outcome( + "call-restamp-1".to_string(), + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::User, + ), ) .await; @@ -4818,7 +4854,7 @@ async fn tool_call_metadata_snapshot_stamps_guardian_approval_source() { let snapshot = ToolApprovalMetadataSnapshot { approval_outcomes_by_call_id: HashMap::from([( "call-guardian-1".to_string(), - crate::state::ApprovalOutcomeMetadata { + ApprovalOutcomeMetadata { review_decision: Some(codex_protocol::models::ReviewDecisionMetadata::Denied), approval_source: codex_protocol::models::ApprovalSourceMetadata::Guardian, }, @@ -4859,7 +4895,7 @@ async fn tool_call_metadata_snapshot_stamps_policy_approval_source_without_revie let snapshot = ToolApprovalMetadataSnapshot { approval_outcomes_by_call_id: HashMap::from([( "call-policy-1".to_string(), - crate::state::ApprovalOutcomeMetadata { + ApprovalOutcomeMetadata { review_decision: None, approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, }, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 8b235d71a5..ac3bd1c5ba 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -32,6 +32,7 @@ use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; +use crate::state::ApprovalOutcomeMetadata; use crate::state_db; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ApprovalSourceMetadata; @@ -533,8 +534,11 @@ async fn maybe_request_mcp_tool_approval( monitor_reason.clone(), ) .await; - sess.record_direct_approval_outcome(call_id, &decision, ApprovalSourceMetadata::Guardian) - .await; + sess.record_call_approval_outcome( + call_id.to_string(), + ApprovalOutcomeMetadata::reviewed(&decision, ApprovalSourceMetadata::Guardian), + ) + .await; let decision = mcp_tool_approval_decision_from_guardian(decision); apply_mcp_tool_approval_decision( sess, diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 55f4ee1f18..bdb79c4ba3 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -79,11 +79,7 @@ pub(crate) struct ApprovalOutcomeMetadata { } impl ApprovalOutcomeMetadata { - pub(crate) fn reviewed(decision: &ReviewDecision) -> Self { - Self::reviewed_with_source(decision, ApprovalSourceMetadata::User) - } - - pub(crate) fn reviewed_with_source( + pub(crate) fn reviewed( decision: &ReviewDecision, approval_source: ApprovalSourceMetadata, ) -> Self { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 5a1a8d2888..adbb0ab8a7 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -11,6 +11,7 @@ use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; +use crate::state::ApprovalOutcomeMetadata; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; @@ -143,10 +144,12 @@ impl Approvable for ApplyPatchRuntime { let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); let decision = review_approval_request(session, turn, action, retry_reason).await; session - .record_direct_approval_outcome( - &call_id, - &decision, - ApprovalSourceMetadata::Guardian, + .record_call_approval_outcome( + call_id.clone(), + ApprovalOutcomeMetadata::reviewed( + &decision, + ApprovalSourceMetadata::Guardian, + ), ) .await; return decision; diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 4557cd88e9..5282cde5ee 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -18,6 +18,7 @@ use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::shell::ShellType; +use crate::state::ApprovalOutcomeMetadata; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; @@ -169,10 +170,12 @@ impl Approvable for ShellRuntime { ) .await; session - .record_direct_approval_outcome( - &call_id, - &decision, - ApprovalSourceMetadata::Guardian, + .record_call_approval_outcome( + call_id.clone(), + ApprovalOutcomeMetadata::reviewed( + &decision, + ApprovalSourceMetadata::Guardian, + ), ) .await; return decision; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index e18be56d75..db1de9a596 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -13,6 +13,7 @@ use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::skills::SkillMetadata; +use crate::state::ApprovalOutcomeMetadata; use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::SandboxAttempt; @@ -440,10 +441,12 @@ impl CoreShellActionProvider { ) .await; session - .record_direct_approval_outcome( - &call_id, - &decision, - ApprovalSourceMetadata::Guardian, + .record_call_approval_outcome( + call_id.clone(), + ApprovalOutcomeMetadata::reviewed( + &decision, + ApprovalSourceMetadata::Guardian, + ), ) .await; return decision; @@ -529,14 +532,24 @@ impl CoreShellActionProvider { ) -> anyhow::Result { let action = match decision { Decision::Forbidden => { - self.session.record_policy_outcome(&self.call_id).await; + self.session + .record_call_approval_outcome( + self.call_id.clone(), + ApprovalOutcomeMetadata::policy(), + ) + .await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } Decision::Prompt => { if execve_prompt_is_rejected_by_policy(self.approval_policy, &decision_source) .is_some() { - self.session.record_policy_outcome(&self.call_id).await; + self.session + .record_call_approval_outcome( + self.call_id.clone(), + ApprovalOutcomeMetadata::policy(), + ) + .await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } else { match self diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index b444938cd4..2ea5f52123 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -15,6 +15,7 @@ use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; +use crate::state::ApprovalOutcomeMetadata; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; @@ -138,10 +139,12 @@ impl Approvable for UnifiedExecRuntime<'_> { ) .await; session - .record_direct_approval_outcome( - &call_id, - &decision, - ApprovalSourceMetadata::Guardian, + .record_call_approval_outcome( + call_id.clone(), + ApprovalOutcomeMetadata::reviewed( + &decision, + ApprovalSourceMetadata::Guardian, + ), ) .await; return decision; From d6001588ce8a6183237dff11bc3b0deb32545661 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 17:55:00 -0700 Subject: [PATCH 05/26] patch network regression --- codex-rs/core/src/codex_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index de11b6af57..d0cf482d1a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4426,9 +4426,7 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: codex_protocol::approvals::ExecPolicyAmendment::new( - vec!["echo".to_string(), "hi".to_string()], - ), + command: vec!["echo".to_string(), "hi".to_string()], }, codex_protocol::models::ApprovalSourceMetadata::User, ) @@ -4438,8 +4436,9 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { - host: "example.com".to_string(), + network_policy_amendment: codex_protocol::protocol::NetworkPolicyRule { + protocol: codex_execpolicy::NetworkRuleProtocol::Https, + host_pattern: "example.com".to_string(), action: codex_protocol::protocol::NetworkPolicyRuleAction::Allow, }, }, @@ -4451,8 +4450,9 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { - host: "example.com".to_string(), + network_policy_amendment: codex_protocol::protocol::NetworkPolicyRule { + protocol: codex_execpolicy::NetworkRuleProtocol::Https, + host_pattern: "example.com".to_string(), action: codex_protocol::protocol::NetworkPolicyRuleAction::Deny, }, }, From 25a0837184a24c0940b6b04d7adcbf5c4ecdbe7a Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 18:06:36 -0700 Subject: [PATCH 06/26] tests --- codex-rs/core/src/codex_tests.rs | 73 ++++++-------------------------- 1 file changed, 13 insertions(+), 60 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index d0cf482d1a..45df694598 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4565,8 +4565,7 @@ async fn assert_next_emitted_function_call_metadata( .. } if metadata.is_tool_call_escalated == Some(expected_escalated) && metadata.review_decision == expected_review_decision - && metadata.approval_source - == expected_approval_source + && metadata.approval_source == expected_approval_source && metadata.sandbox_policy == Some(expected_sandbox_policy) ) )); @@ -4602,39 +4601,13 @@ async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { sess.record_response_item_and_emit_turn_item(tc.as_ref(), function_call_item("call-2")) .await; - assert_next_emitted_function_call_metadata( - &rx, - expected_sandbox_policy, - false, - None, - None, - ) - .await; + assert_next_emitted_function_call_metadata(&rx, expected_sandbox_policy, false, None, None) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() { - let (mut sess, tc, rx) = make_session_and_context_with_rx().await; - let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); - Arc::get_mut(&mut sess) - .expect("session should be uniquely owned in this test") - .features - .enable(crate::features::Feature::ItemMetadata) - .expect("feature flag should be enabled for this test"); - - sess.spawn_task( - Arc::clone(&tc), - vec![UserInput::Text { - text: "start".to_string(), - text_elements: Vec::new(), - }], - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - while rx.try_recv().is_ok() {} + let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; sess.record_call_approval_outcome( "call-guardian-runtime-1".to_string(), @@ -4646,37 +4619,17 @@ async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-guardian-runtime-1".to_string(), - metadata: None, - }, + function_call_item("call-guardian-runtime-1"), + ) + .await; + assert_next_emitted_function_call_metadata( + &rx, + expected_sandbox_policy, + true, + Some(codex_protocol::models::ReviewDecisionMetadata::Denied), + Some(codex_protocol::models::ApprovalSourceMetadata::Guardian), ) .await; - - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected raw response item event") - .expect("channel open"); - assert!(matches!( - event.msg, - EventMsg::RawResponseItem(ref ev) - if matches!( - &ev.item, - ResponseItem::FunctionCall { - metadata: Some(metadata), - .. - } if metadata.is_tool_call_escalated == Some(true) - && metadata.review_decision - == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) - && metadata.approval_source - == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) - && metadata.sandbox_policy == Some(expected_sandbox_policy) - ) - )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 862e415229f8b9697488cc8cdc33f22c56c16d09 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 18:14:31 -0700 Subject: [PATCH 07/26] protocol types --- codex-rs/core/src/codex_tests.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 45df694598..992e5c07da 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4426,7 +4426,9 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::ApprovedExecpolicyAmendment { - command: vec!["echo".to_string(), "hi".to_string()], + proposed_execpolicy_amendment: codex_protocol::approvals::ExecPolicyAmendment::new( + vec!["echo".to_string(), "hi".to_string()], + ), }, codex_protocol::models::ApprovalSourceMetadata::User, ) @@ -4436,10 +4438,9 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::protocol::NetworkPolicyRule { - protocol: codex_execpolicy::NetworkRuleProtocol::Https, - host_pattern: "example.com".to_string(), - action: codex_protocol::protocol::NetworkPolicyRuleAction::Allow, + network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Allow, }, }, codex_protocol::models::ApprovalSourceMetadata::User, @@ -4450,10 +4451,9 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::protocol::NetworkPolicyRule { - protocol: codex_execpolicy::NetworkRuleProtocol::Https, - host_pattern: "example.com".to_string(), - action: codex_protocol::protocol::NetworkPolicyRuleAction::Deny, + network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Deny, }, }, codex_protocol::models::ApprovalSourceMetadata::User, From 4bd85ecf46edca371393f97e724884f9ca556bf9 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 18:31:13 -0700 Subject: [PATCH 08/26] inline policy --- codex-rs/core/src/codex_tests.rs | 5 ++++- codex-rs/core/src/state/turn.rs | 7 ------- .../core/src/tools/runtimes/shell/unix_escalation.rs | 10 ++++++++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 992e5c07da..0acf8fcbe9 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4638,7 +4638,10 @@ async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_fe sess.record_call_approval_outcome( "call-policy-runtime-1".to_string(), - ApprovalOutcomeMetadata::policy(), + ApprovalOutcomeMetadata { + review_decision: None, + approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, + }, ) .await; sess.record_response_item_and_emit_turn_item( diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index bdb79c4ba3..b39b1b1ca8 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -107,13 +107,6 @@ impl ApprovalOutcomeMetadata { approval_source, } } - - pub(crate) fn policy() -> Self { - Self { - review_decision: None, - approval_source: ApprovalSourceMetadata::Policy, - } - } } impl ActiveTurn { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index db1de9a596..0e171ae830 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -535,7 +535,10 @@ impl CoreShellActionProvider { self.session .record_call_approval_outcome( self.call_id.clone(), - ApprovalOutcomeMetadata::policy(), + ApprovalOutcomeMetadata { + review_decision: None, + approval_source: ApprovalSourceMetadata::Policy, + }, ) .await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) @@ -547,7 +550,10 @@ impl CoreShellActionProvider { self.session .record_call_approval_outcome( self.call_id.clone(), - ApprovalOutcomeMetadata::policy(), + ApprovalOutcomeMetadata { + review_decision: None, + approval_source: ApprovalSourceMetadata::Policy, + }, ) .await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) From 07dc4598f38ffc71c832c002ab275306d8ea6e53 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 09:25:38 -0700 Subject: [PATCH 09/26] decrease branch churn --- codex-rs/core/src/codex_tests.rs | 337 ++++++++++++++++--------------- 1 file changed, 178 insertions(+), 159 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 0acf8fcbe9..3a940deff1 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4502,12 +4502,8 @@ fn sandbox_policy_metadata_mapping_is_stable() { ); } -async fn setup_tool_call_metadata_runtime_test() -> ( - Arc, - Arc, - async_channel::Receiver, - codex_protocol::models::SandboxPolicyMetadata, -) { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enabled() { let (mut sess, tc, rx) = make_session_and_context_with_rx().await; let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); Arc::get_mut(&mut sess) @@ -4530,27 +4526,27 @@ async fn setup_tool_call_metadata_runtime_test() -> ( .await; while rx.try_recv().is_ok() {} - (sess, tc, rx, expected_sandbox_policy) -} + sess.record_call_approval_outcome( + "call-1".to_string(), + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::User, + ), + ) + .await; + sess.record_response_item_and_emit_turn_item( + tc.as_ref(), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + metadata: None, + }, + ) + .await; -fn function_call_item(call_id: &str) -> ResponseItem { - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: call_id.to_string(), - metadata: None, - } -} - -async fn assert_next_emitted_function_call_metadata( - rx: &async_channel::Receiver, - expected_sandbox_policy: codex_protocol::models::SandboxPolicyMetadata, - expected_escalated: bool, - expected_review_decision: Option, - expected_approval_source: Option, -) { let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("expected raw response item event") @@ -4563,51 +4559,96 @@ async fn assert_next_emitted_function_call_metadata( ResponseItem::FunctionCall { metadata: Some(metadata), .. - } if metadata.is_tool_call_escalated == Some(expected_escalated) - && metadata.review_decision == expected_review_decision - && metadata.approval_source == expected_approval_source + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision + == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::User) && metadata.sandbox_policy == Some(expected_sandbox_policy) ) )); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enabled() { - let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; - - sess.record_call_approval_outcome( - "call-1".to_string(), - ApprovalOutcomeMetadata::reviewed( - &ReviewDecision::Denied, - codex_protocol::models::ApprovalSourceMetadata::User, - ), - ) - .await; - sess.record_response_item_and_emit_turn_item(tc.as_ref(), function_call_item("call-1")) - .await; - assert_next_emitted_function_call_metadata( - &rx, - expected_sandbox_policy, - true, - Some(codex_protocol::models::ReviewDecisionMetadata::Denied), - Some(codex_protocol::models::ApprovalSourceMetadata::User), - ) - .await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { - let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; + let (mut sess, tc, rx) = make_session_and_context_with_rx().await; + let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); + Arc::get_mut(&mut sess) + .expect("session should be uniquely owned in this test") + .features + .enable(crate::features::Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); - sess.record_response_item_and_emit_turn_item(tc.as_ref(), function_call_item("call-2")) - .await; - assert_next_emitted_function_call_metadata(&rx, expected_sandbox_policy, false, None, None) - .await; + sess.spawn_task( + Arc::clone(&tc), + vec![UserInput::Text { + text: "start".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + while rx.try_recv().is_ok() {} + + sess.record_response_item_and_emit_turn_item( + tc.as_ref(), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-2".to_string(), + metadata: None, + }, + ) + .await; + + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!( + event.msg, + EventMsg::RawResponseItem(ref ev) + if matches!( + &ev.item, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(false) + && metadata.review_decision.is_none() + && metadata.approval_source.is_none() + && metadata.sandbox_policy == Some(expected_sandbox_policy) + ) + )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() { - let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; + let (mut sess, tc, rx) = make_session_and_context_with_rx().await; + let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); + Arc::get_mut(&mut sess) + .expect("session should be uniquely owned in this test") + .features + .enable(crate::features::Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); + + sess.spawn_task( + Arc::clone(&tc), + vec![UserInput::Text { + text: "start".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + while rx.try_recv().is_ok() {} sess.record_call_approval_outcome( "call-guardian-runtime-1".to_string(), @@ -4619,22 +4660,62 @@ async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), - function_call_item("call-guardian-runtime-1"), - ) - .await; - assert_next_emitted_function_call_metadata( - &rx, - expected_sandbox_policy, - true, - Some(codex_protocol::models::ReviewDecisionMetadata::Denied), - Some(codex_protocol::models::ApprovalSourceMetadata::Guardian), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-guardian-runtime-1".to_string(), + metadata: None, + }, ) .await; + + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!( + event.msg, + EventMsg::RawResponseItem(ref ev) + if matches!( + &ev.item, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision + == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) + && metadata.sandbox_policy == Some(expected_sandbox_policy) + ) + )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_feature_enabled() { - let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; + let (mut sess, tc, rx) = make_session_and_context_with_rx().await; + let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); + Arc::get_mut(&mut sess) + .expect("session should be uniquely owned in this test") + .features + .enable(crate::features::Feature::ItemMetadata) + .expect("feature flag should be enabled for this test"); + + sess.spawn_task( + Arc::clone(&tc), + vec![UserInput::Text { + text: "start".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + while rx.try_recv().is_ok() {} sess.record_call_approval_outcome( "call-policy-runtime-1".to_string(), @@ -4646,17 +4727,36 @@ async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_fe .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), - function_call_item("call-policy-runtime-1"), - ) - .await; - assert_next_emitted_function_call_metadata( - &rx, - expected_sandbox_policy, - true, - None, - Some(codex_protocol::models::ApprovalSourceMetadata::Policy), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-policy-runtime-1".to_string(), + metadata: None, + }, ) .await; + + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!( + event.msg, + EventMsg::RawResponseItem(ref ev) + if matches!( + &ev.item, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision.is_none() + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::Policy) + && metadata.sandbox_policy == Some(expected_sandbox_policy) + ) + )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -4794,87 +4894,6 @@ async fn tool_call_metadata_can_be_restamped_after_approval_outcome() { )); } -#[tokio::test] -async fn tool_call_metadata_snapshot_stamps_guardian_approval_source() { - let (_session, turn_context_raw) = make_session_and_context().await; - let turn_context = Arc::new(turn_context_raw); - let expected_sandbox_policy = sandbox_policy_to_metadata(turn_context.sandbox_policy.get()); - let item = ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-guardian-1".to_string(), - metadata: None, - }; - let snapshot = ToolApprovalMetadataSnapshot { - approval_outcomes_by_call_id: HashMap::from([( - "call-guardian-1".to_string(), - ApprovalOutcomeMetadata { - review_decision: Some(codex_protocol::models::ReviewDecisionMetadata::Denied), - approval_source: codex_protocol::models::ApprovalSourceMetadata::Guardian, - }, - )]), - pending_approval_call_ids: HashSet::new(), - }; - - let stamped = - stamp_tool_approval_metadata_with_snapshot(turn_context.as_ref(), item, Some(&snapshot)); - - assert!(matches!( - stamped, - ResponseItem::FunctionCall { - metadata: Some(metadata), - .. - } if metadata.is_tool_call_escalated == Some(true) - && metadata.review_decision - == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) - && metadata.approval_source - == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) - && metadata.sandbox_policy == Some(expected_sandbox_policy) - )); -} - -#[tokio::test] -async fn tool_call_metadata_snapshot_stamps_policy_approval_source_without_review_decision() { - let (_session, turn_context_raw) = make_session_and_context().await; - let turn_context = Arc::new(turn_context_raw); - let expected_sandbox_policy = sandbox_policy_to_metadata(turn_context.sandbox_policy.get()); - let item = ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-policy-1".to_string(), - metadata: None, - }; - let snapshot = ToolApprovalMetadataSnapshot { - approval_outcomes_by_call_id: HashMap::from([( - "call-policy-1".to_string(), - ApprovalOutcomeMetadata { - review_decision: None, - approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, - }, - )]), - pending_approval_call_ids: HashSet::new(), - }; - - let stamped = - stamp_tool_approval_metadata_with_snapshot(turn_context.as_ref(), item, Some(&snapshot)); - - assert!(matches!( - stamped, - ResponseItem::FunctionCall { - metadata: Some(metadata), - .. - } if metadata.is_tool_call_escalated == Some(true) - && metadata.review_decision.is_none() - && metadata.approval_source - == Some(codex_protocol::models::ApprovalSourceMetadata::Policy) - && metadata.sandbox_policy == Some(expected_sandbox_policy) - )); -} - #[tokio::test] async fn steer_input_requires_active_turn() { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; From 0e0846d032f77bc24fd21339ca6a8aede55cdd18 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 09:34:07 -0700 Subject: [PATCH 10/26] test refactoring --- codex-rs/core/src/codex_tests.rs | 268 ++++++++++--------------------- 1 file changed, 84 insertions(+), 184 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a940deff1..826858b067 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4502,8 +4502,12 @@ fn sandbox_policy_metadata_mapping_is_stable() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enabled() { +async fn setup_tool_call_metadata_runtime_test() -> ( + Arc, + Arc, + async_channel::Receiver, + codex_protocol::models::SandboxPolicyMetadata, +) { let (mut sess, tc, rx) = make_session_and_context_with_rx().await; let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); Arc::get_mut(&mut sess) @@ -4526,6 +4530,51 @@ async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enable .await; while rx.try_recv().is_ok() {} + (sess, tc, rx, expected_sandbox_policy) +} + +fn function_call_item(call_id: &str) -> ResponseItem { + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: call_id.to_string(), + metadata: None, + } +} + +async fn assert_next_emitted_function_call_metadata( + rx: &async_channel::Receiver, + expected_sandbox_policy: codex_protocol::models::SandboxPolicyMetadata, + expected_escalated: bool, + expected_review_decision: Option, + expected_approval_source: Option, +) { + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!( + event.msg, + EventMsg::RawResponseItem(ref ev) + if matches!( + &ev.item, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(expected_escalated) + && metadata.review_decision == expected_review_decision + && metadata.approval_source == expected_approval_source + && metadata.sandbox_policy == Some(expected_sandbox_policy) + ) + )); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enabled() { + let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; + sess.record_call_approval_outcome( "call-1".to_string(), ApprovalOutcomeMetadata::reviewed( @@ -4534,121 +4583,31 @@ async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enable ), ) .await; - sess.record_response_item_and_emit_turn_item( - tc.as_ref(), - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - metadata: None, - }, + sess.record_response_item_and_emit_turn_item(tc.as_ref(), function_call_item("call-1")) + .await; + assert_next_emitted_function_call_metadata( + &rx, + expected_sandbox_policy, + true, + Some(codex_protocol::models::ReviewDecisionMetadata::Denied), + Some(codex_protocol::models::ApprovalSourceMetadata::User), ) .await; - - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected raw response item event") - .expect("channel open"); - assert!(matches!( - event.msg, - EventMsg::RawResponseItem(ref ev) - if matches!( - &ev.item, - ResponseItem::FunctionCall { - metadata: Some(metadata), - .. - } if metadata.is_tool_call_escalated == Some(true) - && metadata.review_decision - == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) - && metadata.approval_source - == Some(codex_protocol::models::ApprovalSourceMetadata::User) - && metadata.sandbox_policy == Some(expected_sandbox_policy) - ) - )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { - let (mut sess, tc, rx) = make_session_and_context_with_rx().await; - let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); - Arc::get_mut(&mut sess) - .expect("session should be uniquely owned in this test") - .features - .enable(crate::features::Feature::ItemMetadata) - .expect("feature flag should be enabled for this test"); + let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; - sess.spawn_task( - Arc::clone(&tc), - vec![UserInput::Text { - text: "start".to_string(), - text_elements: Vec::new(), - }], - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - while rx.try_recv().is_ok() {} - - sess.record_response_item_and_emit_turn_item( - tc.as_ref(), - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-2".to_string(), - metadata: None, - }, - ) - .await; - - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected raw response item event") - .expect("channel open"); - assert!(matches!( - event.msg, - EventMsg::RawResponseItem(ref ev) - if matches!( - &ev.item, - ResponseItem::FunctionCall { - metadata: Some(metadata), - .. - } if metadata.is_tool_call_escalated == Some(false) - && metadata.review_decision.is_none() - && metadata.approval_source.is_none() - && metadata.sandbox_policy == Some(expected_sandbox_policy) - ) - )); + sess.record_response_item_and_emit_turn_item(tc.as_ref(), function_call_item("call-2")) + .await; + assert_next_emitted_function_call_metadata(&rx, expected_sandbox_policy, false, None, None) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() { - let (mut sess, tc, rx) = make_session_and_context_with_rx().await; - let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); - Arc::get_mut(&mut sess) - .expect("session should be uniquely owned in this test") - .features - .enable(crate::features::Feature::ItemMetadata) - .expect("feature flag should be enabled for this test"); - - sess.spawn_task( - Arc::clone(&tc), - vec![UserInput::Text { - text: "start".to_string(), - text_elements: Vec::new(), - }], - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - while rx.try_recv().is_ok() {} + let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; sess.record_call_approval_outcome( "call-guardian-runtime-1".to_string(), @@ -4660,62 +4619,22 @@ async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-guardian-runtime-1".to_string(), - metadata: None, - }, + function_call_item("call-guardian-runtime-1"), + ) + .await; + assert_next_emitted_function_call_metadata( + &rx, + expected_sandbox_policy, + true, + Some(codex_protocol::models::ReviewDecisionMetadata::Denied), + Some(codex_protocol::models::ApprovalSourceMetadata::Guardian), ) .await; - - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected raw response item event") - .expect("channel open"); - assert!(matches!( - event.msg, - EventMsg::RawResponseItem(ref ev) - if matches!( - &ev.item, - ResponseItem::FunctionCall { - metadata: Some(metadata), - .. - } if metadata.is_tool_call_escalated == Some(true) - && metadata.review_decision - == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) - && metadata.approval_source - == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) - && metadata.sandbox_policy == Some(expected_sandbox_policy) - ) - )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_feature_enabled() { - let (mut sess, tc, rx) = make_session_and_context_with_rx().await; - let expected_sandbox_policy = sandbox_policy_to_metadata(tc.sandbox_policy.get()); - Arc::get_mut(&mut sess) - .expect("session should be uniquely owned in this test") - .features - .enable(crate::features::Feature::ItemMetadata) - .expect("feature flag should be enabled for this test"); - - sess.spawn_task( - Arc::clone(&tc), - vec![UserInput::Text { - text: "start".to_string(), - text_elements: Vec::new(), - }], - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - while rx.try_recv().is_ok() {} + let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; sess.record_call_approval_outcome( "call-policy-runtime-1".to_string(), @@ -4727,36 +4646,17 @@ async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_fe .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-policy-runtime-1".to_string(), - metadata: None, - }, + function_call_item("call-policy-runtime-1"), + ) + .await; + assert_next_emitted_function_call_metadata( + &rx, + expected_sandbox_policy, + true, + None, + Some(codex_protocol::models::ApprovalSourceMetadata::Policy), ) .await; - - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected raw response item event") - .expect("channel open"); - assert!(matches!( - event.msg, - EventMsg::RawResponseItem(ref ev) - if matches!( - &ev.item, - ResponseItem::FunctionCall { - metadata: Some(metadata), - .. - } if metadata.is_tool_call_escalated == Some(true) - && metadata.review_decision.is_none() - && metadata.approval_source - == Some(codex_protocol::models::ApprovalSourceMetadata::Policy) - && metadata.sandbox_policy == Some(expected_sandbox_policy) - ) - )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 5646c5a828290ef0f6f6a19773a6a0b63ea2af14 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 09:47:54 -0700 Subject: [PATCH 11/26] add uuid to metadata --- codex-rs/app-server/src/bespoke_event_handling.rs | 3 +++ codex-rs/core/src/client_common.rs | 7 ++++++- codex-rs/core/src/state/turn.rs | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 26e0e8bb16..00be6ef686 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2815,8 +2815,11 @@ mod tests { use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; + use codex_protocol::models::ContentItem; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; + use codex_protocol::models::ResponseItem; + use codex_protocol::models::ResponseItemMetadata; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::protocol::CollabResumeBeginEvent; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index ea6a209e2e..b87bc1ee34 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -46,7 +46,12 @@ pub struct Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - let mut input = self.input.clone(); + let mut input: Vec = self + .input + .clone() + .into_iter() + .map(ResponseItem::with_generated_metadata_uuid) + .collect(); // when using the *Freeform* apply_patch tool specifically, tool outputs // should be structured text, not json. Do NOT reserialize when using diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index b39b1b1ca8..bdb79c4ba3 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -107,6 +107,13 @@ impl ApprovalOutcomeMetadata { approval_source, } } + + pub(crate) fn policy() -> Self { + Self { + review_decision: None, + approval_source: ApprovalSourceMetadata::Policy, + } + } } impl ActiveTurn { From 910bc90d7865a6f581881f01594637fcc1139ad4 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 11:16:54 -0700 Subject: [PATCH 12/26] rerun ci on latest head From f6f5e1608ba6be5fbbae53a5ccd5e00bb89c795d Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Tue, 17 Mar 2026 22:09:22 -0700 Subject: [PATCH 13/26] [hooks] userpromptsubmit - hook before user's prompt is executed (#14626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - this allows blocking the user's prompts from executing, and also prevents them from entering history - handles the edge case where you can both prevent the user's prompt AND add n amount of additionalContexts - refactors some old code into common.rs where hooks overlap functionality - refactors additionalContext being previously added to user messages, instead we use developer messages for them - handles queued messages correctly Sample hook for testing - if you write "[block-user-submit]" this hook will stop the thread: example run ``` › sup • Running UserPromptSubmit hook: reading the observatory notes UserPromptSubmit hook (completed) warning: wizard-tower UserPromptSubmit demo inspected: sup hook context: Wizard Tower UserPromptSubmit demo fired. For this reply only, include the exact phrase 'observatory lanterns lit' exactly once near the end. • Just riding the cosmic wave and ready to help, my friend. What are we building today? observatory lanterns lit › and [block-user-submit] • Running UserPromptSubmit hook: reading the observatory notes UserPromptSubmit hook (stopped) warning: wizard-tower UserPromptSubmit demo blocked the prompt on purpose. stop: Wizard Tower demo block: remove [block-user-submit] to continue. ``` .codex/config.toml ``` [features] codex_hooks = true ``` .codex/hooks.json ``` { "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "/usr/bin/python3 .codex/hooks/user_prompt_submit_demo.py", "timeoutSec": 10, "statusMessage": "reading the observatory notes" } ] } ] } } ``` .codex/hooks/user_prompt_submit_demo.py ``` import json import sys from pathlib import Path def prompt_from_payload(payload: dict) -> str: prompt = payload.get("prompt") if isinstance(prompt, str) and prompt.strip(): return prompt.strip() event = payload.get("event") if isinstance(event, dict): user_prompt = event.get("user_prompt") if isinstance(user_prompt, str): return user_prompt.strip() return "" def main() -> int: payload = json.load(sys.stdin) prompt = prompt_from_payload(payload) cwd = Path(payload.get("cwd", ".")).name or "wizard-tower" if "[block-user-submit]" in prompt: print( json.dumps( { "systemMessage": ( f"{cwd} UserPromptSubmit demo blocked the prompt on purpose." ), "decision": "block", "reason": ( "Wizard Tower demo block: remove [block-user-submit] to continue." ), } ) ) return 0 prompt_preview = prompt or "(empty prompt)" if len(prompt_preview) > 80: prompt_preview = f"{prompt_preview[:77]}..." print( json.dumps( { "systemMessage": ( f"{cwd} UserPromptSubmit demo inspected: {prompt_preview}" ), "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": ( "Wizard Tower UserPromptSubmit demo fired. " "For this reply only, include the exact phrase " "'observatory lanterns lit' exactly once near the end." ), }, } ) ) return 0 if __name__ == "__main__": raise SystemExit(main()) ``` --- codex-rs/core/src/codex.rs | 33 ++++++++++++++++++++++++++----- codex-rs/core/src/hook_runtime.rs | 12 ++++++++--- codex-rs/core/src/state/turn.rs | 9 +++++++++ codex-rs/core/src/tasks/mod.rs | 10 ++-------- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea12c23ba4..02ea36ffd2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -215,6 +215,12 @@ use crate::hook_runtime::record_pending_input; use crate::hook_runtime::run_pending_session_start_hooks; use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::guardian::routes_approval_to_guardian; +use crate::hook_runtime::PendingInputHookDisposition; +use crate::hook_runtime::inspect_pending_input; +use crate::hook_runtime::record_additional_contexts; +use crate::hook_runtime::record_pending_input; +use crate::hook_runtime::run_pending_session_start_hooks; +use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::instructions::UserInstructions; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::McpManager; @@ -4237,6 +4243,21 @@ impl Session { } } + pub async fn prepend_pending_input_with_metadata( + &self, + input: Vec, + ) -> Result<(), ()> { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.prepend_pending_input_with_metadata(input); + Ok(()) + } + None => Err(()), + } + } + pub async fn get_pending_input_with_metadata( &self, ) -> Vec<(ResponseInputItem, Option)> { @@ -6002,11 +6023,13 @@ pub(crate) async fn run_turn( let mut requeued_pending_input = false; let mut accepted_pending_input = Vec::new(); if !pending_response_items.is_empty() { - let mut pending_input_iter = pending_response_items.into_iter(); - while let Some((pending_input_item, message_metadata)) = pending_input_iter.next() { + let mut pending_input_iter = pending_response_items + .into_iter() + .map(|(input, metadata)| crate::state::PendingInputItem { input, metadata }); + while let Some(pending_input_item) = pending_input_iter.next() { match inspect_pending_input(&sess, &turn_context, pending_input_item).await { PendingInputHookDisposition::Accepted(pending_input) => { - accepted_pending_input.push((*pending_input, message_metadata)); + accepted_pending_input.push(*pending_input); } PendingInputHookDisposition::Blocked { additional_contexts, @@ -6027,8 +6050,8 @@ pub(crate) async fn run_turn( } let has_accepted_pending_input = !accepted_pending_input.is_empty(); - for (pending_input, message_metadata) in accepted_pending_input { - record_pending_input(&sess, &turn_context, pending_input, message_metadata).await; + for pending_input in accepted_pending_input { + record_pending_input(&sess, &turn_context, pending_input).await; } record_additional_contexts(&sess, &turn_context, blocked_pending_input_contexts).await; diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index d185e1a86d..686db72c71 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -6,7 +6,6 @@ use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_protocol::items::TurnItem; use codex_protocol::models::DeveloperInstructions; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::AskForApproval; @@ -18,6 +17,7 @@ use codex_protocol::user_input::UserInput; use crate::codex::Session; use crate::codex::TurnContext; use crate::event_mapping::parse_turn_item; +use crate::state::PendingInputItem; pub(crate) struct HookRuntimeOutcome { pub should_stop: bool, @@ -33,6 +33,7 @@ pub(crate) enum PendingInputRecord { UserMessage { content: Vec, response_item: ResponseItem, + message_metadata: Option, additional_contexts: Vec, }, ConversationItem { @@ -137,8 +138,12 @@ pub(crate) async fn run_user_prompt_submit_hooks( pub(crate) async fn inspect_pending_input( sess: &Arc, turn_context: &Arc, - pending_input_item: ResponseInputItem, + pending_input_item: PendingInputItem, ) -> PendingInputHookDisposition { + let PendingInputItem { + input: pending_input_item, + metadata: message_metadata, + } = pending_input_item; let response_item = ResponseItem::from(pending_input_item); if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) { let user_prompt_submit_outcome = @@ -151,6 +156,7 @@ pub(crate) async fn inspect_pending_input( PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::UserMessage { content: user_message.content, response_item, + message_metadata, additional_contexts: user_prompt_submit_outcome.additional_contexts, })) } @@ -165,12 +171,12 @@ pub(crate) async fn record_pending_input( sess: &Arc, turn_context: &Arc, pending_input: PendingInputRecord, - message_metadata: Option, ) { match pending_input { PendingInputRecord::UserMessage { content, response_item, + message_metadata, additional_contexts, } => { sess.record_user_prompt_and_emit_turn_item( diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index bdb79c4ba3..e4b4c1c9ed 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -284,6 +284,15 @@ impl TurnState { .push(PendingInputItem { input, metadata }); } + pub(crate) fn prepend_pending_input_with_metadata(&mut self, mut input: Vec) { + if input.is_empty() { + return; + } + + input.append(&mut self.pending_input); + self.pending_input = input; + } + pub(crate) fn take_pending_input_with_metadata(&mut self) -> Vec { if self.pending_input.is_empty() { Vec::with_capacity(0) diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ef03f0045c..af8fbedbdf 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -263,15 +263,9 @@ impl Session { drop(active); if !pending_input.is_empty() { for pending_input_item in pending_input { - match inspect_pending_input(self, &turn_context, pending_input_item.input).await { + match inspect_pending_input(self, &turn_context, pending_input_item).await { PendingInputHookDisposition::Accepted(pending_input) => { - record_pending_input( - self, - &turn_context, - *pending_input, - pending_input_item.metadata, - ) - .await; + record_pending_input(self, &turn_context, *pending_input).await; } PendingInputHookDisposition::Blocked { additional_contexts, From c265a1a0ba4dd8c7494c432c57b6aa4342f97b60 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 13:59:01 -0700 Subject: [PATCH 14/26] merge conflicts --- codex-rs/core/src/codex.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 02ea36ffd2..53670eba73 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4293,7 +4293,6 @@ impl Session { None => Err(()), } } - pub async fn has_pending_input(&self) -> bool { let active = self.active_turn.lock().await; match active.as_ref() { From ef7f3b189e2ad9c66a0ff3ea180ff68bc1185c9f Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 15:20:34 -0700 Subject: [PATCH 15/26] move uuid stamping callsite to earliest boundary --- codex-rs/core/src/client_common.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index b87bc1ee34..ea6a209e2e 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -46,12 +46,7 @@ pub struct Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - let mut input: Vec = self - .input - .clone() - .into_iter() - .map(ResponseItem::with_generated_metadata_uuid) - .collect(); + let mut input = self.input.clone(); // when using the *Freeform* apply_patch tool specifically, tool outputs // should be structured text, not json. Do NOT reserialize when using From 11c1cd375c9124479a174bcdfcf5585d5a8d9bcd Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 14:39:21 -0700 Subject: [PATCH 16/26] initial draft --- codex-rs/core/src/codex_tests.rs | 90 +++++++++++++++++-- .../tools/runtimes/shell/unix_escalation.rs | 20 +---- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 826858b067..c9f4ae18cb 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4636,14 +4636,7 @@ async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_feature_enabled() { let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; - sess.record_call_approval_outcome( - "call-policy-runtime-1".to_string(), - ApprovalOutcomeMetadata { - review_decision: None, - approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, - }, - ) - .await; + sess.record_policy_outcome("call-policy-runtime-1").await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), function_call_item("call-policy-runtime-1"), @@ -4794,6 +4787,87 @@ async fn tool_call_metadata_can_be_restamped_after_approval_outcome() { )); } +#[tokio::test] +async fn tool_call_metadata_snapshot_stamps_guardian_approval_source() { + let (_session, turn_context_raw) = make_session_and_context().await; + let turn_context = Arc::new(turn_context_raw); + let expected_sandbox_policy = sandbox_policy_to_metadata(turn_context.sandbox_policy.get()); + let item = ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-guardian-1".to_string(), + metadata: None, + }; + let snapshot = ToolApprovalMetadataSnapshot { + approval_outcomes_by_call_id: HashMap::from([( + "call-guardian-1".to_string(), + crate::state::ApprovalOutcomeMetadata { + review_decision: Some(codex_protocol::models::ReviewDecisionMetadata::Denied), + approval_source: codex_protocol::models::ApprovalSourceMetadata::Guardian, + }, + )]), + pending_approval_call_ids: HashSet::new(), + }; + + let stamped = + stamp_tool_approval_metadata_with_snapshot(turn_context.as_ref(), item, Some(&snapshot)); + + assert!(matches!( + stamped, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision + == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) + && metadata.sandbox_policy == Some(expected_sandbox_policy) + )); +} + +#[tokio::test] +async fn tool_call_metadata_snapshot_stamps_policy_approval_source_without_review_decision() { + let (_session, turn_context_raw) = make_session_and_context().await; + let turn_context = Arc::new(turn_context_raw); + let expected_sandbox_policy = sandbox_policy_to_metadata(turn_context.sandbox_policy.get()); + let item = ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-policy-1".to_string(), + metadata: None, + }; + let snapshot = ToolApprovalMetadataSnapshot { + approval_outcomes_by_call_id: HashMap::from([( + "call-policy-1".to_string(), + crate::state::ApprovalOutcomeMetadata { + review_decision: None, + approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, + }, + )]), + pending_approval_call_ids: HashSet::new(), + }; + + let stamped = + stamp_tool_approval_metadata_with_snapshot(turn_context.as_ref(), item, Some(&snapshot)); + + assert!(matches!( + stamped, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision.is_none() + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::Policy) + && metadata.sandbox_policy == Some(expected_sandbox_policy) + )); +} + #[tokio::test] async fn steer_input_requires_active_turn() { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 0e171ae830..b37966eb86 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -532,30 +532,14 @@ impl CoreShellActionProvider { ) -> anyhow::Result { let action = match decision { Decision::Forbidden => { - self.session - .record_call_approval_outcome( - self.call_id.clone(), - ApprovalOutcomeMetadata { - review_decision: None, - approval_source: ApprovalSourceMetadata::Policy, - }, - ) - .await; + self.session.record_policy_outcome(&self.call_id).await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } Decision::Prompt => { if execve_prompt_is_rejected_by_policy(self.approval_policy, &decision_source) .is_some() { - self.session - .record_call_approval_outcome( - self.call_id.clone(), - ApprovalOutcomeMetadata { - review_decision: None, - approval_source: ApprovalSourceMetadata::Policy, - }, - ) - .await; + self.session.record_policy_outcome(&self.call_id).await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } else { match self From b4a80c5bf06731ecebe3a257f103742b78628e04 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 15:43:11 -0700 Subject: [PATCH 17/26] Trigger CI From 2a5d70bece1aea998e52855a44425a22805f34f3 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 16:49:25 -0700 Subject: [PATCH 18/26] guardian patch and test update --- codex-rs/core/src/codex.rs | 18 +++++- codex-rs/core/src/codex_tests.rs | 58 ++++++++++++------- codex-rs/core/src/mcp_tool_call.rs | 8 +-- .../core/src/tools/runtimes/apply_patch.rs | 11 ++-- codex-rs/core/src/tools/runtimes/shell.rs | 11 ++-- .../tools/runtimes/shell/unix_escalation.rs | 11 ++-- .../core/src/tools/runtimes/unified_exec.rs | 11 ++-- 7 files changed, 71 insertions(+), 57 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 53670eba73..9ef02e7ea6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3421,9 +3421,23 @@ impl Session { }); drop(ts); drop(active); + self.record_direct_approval_outcome( + &pending_approval.call_id, + decision, + pending_approval.approval_source, + ) + .await; + } + + pub async fn record_direct_approval_outcome( + &self, + call_id: &str, + decision: &ReviewDecision, + approval_source: ApprovalSourceMetadata, + ) { self.record_call_approval_outcome( - pending_approval.call_id, - ApprovalOutcomeMetadata::reviewed(decision, pending_approval.approval_source), + call_id.to_string(), + ApprovalOutcomeMetadata::reviewed(decision, approval_source), ) .await; } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index c9f4ae18cb..376f94c143 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4609,27 +4609,45 @@ async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() { let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; - sess.record_call_approval_outcome( - "call-guardian-runtime-1".to_string(), - ApprovalOutcomeMetadata::reviewed( - &ReviewDecision::Denied, - codex_protocol::models::ApprovalSourceMetadata::Guardian, - ), + sess.record_direct_approval_outcome( + "call-guardian-runtime-1", + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::Guardian, ) .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), - function_call_item("call-guardian-runtime-1"), - ) - .await; - assert_next_emitted_function_call_metadata( - &rx, - expected_sandbox_policy, - true, - Some(codex_protocol::models::ReviewDecisionMetadata::Denied), - Some(codex_protocol::models::ApprovalSourceMetadata::Guardian), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-guardian-runtime-1".to_string(), + metadata: None, + }, ) .await; + + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!( + event.msg, + EventMsg::RawResponseItem(ref ev) + if matches!( + &ev.item, + ResponseItem::FunctionCall { + metadata: Some(metadata), + .. + } if metadata.is_tool_call_escalated == Some(true) + && metadata.review_decision + == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + && metadata.approval_source + == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) + && metadata.sandbox_policy == Some(expected_sandbox_policy) + ) + )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -4746,12 +4764,10 @@ async fn tool_call_metadata_can_be_restamped_after_approval_outcome() { }, ) .await; - sess.record_call_approval_outcome( - "call-restamp-1".to_string(), - ApprovalOutcomeMetadata::reviewed( - &ReviewDecision::Denied, - codex_protocol::models::ApprovalSourceMetadata::User, - ), + sess.record_direct_approval_outcome( + "call-restamp-1", + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::User, ) .await; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index ac3bd1c5ba..8b235d71a5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -32,7 +32,6 @@ use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; -use crate::state::ApprovalOutcomeMetadata; use crate::state_db; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ApprovalSourceMetadata; @@ -534,11 +533,8 @@ async fn maybe_request_mcp_tool_approval( monitor_reason.clone(), ) .await; - sess.record_call_approval_outcome( - call_id.to_string(), - ApprovalOutcomeMetadata::reviewed(&decision, ApprovalSourceMetadata::Guardian), - ) - .await; + sess.record_direct_approval_outcome(call_id, &decision, ApprovalSourceMetadata::Guardian) + .await; let decision = mcp_tool_approval_decision_from_guardian(decision); apply_mcp_tool_approval_decision( sess, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index adbb0ab8a7..5a1a8d2888 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -11,7 +11,6 @@ use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; -use crate::state::ApprovalOutcomeMetadata; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; @@ -144,12 +143,10 @@ impl Approvable for ApplyPatchRuntime { let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); let decision = review_approval_request(session, turn, action, retry_reason).await; session - .record_call_approval_outcome( - call_id.clone(), - ApprovalOutcomeMetadata::reviewed( - &decision, - ApprovalSourceMetadata::Guardian, - ), + .record_direct_approval_outcome( + &call_id, + &decision, + ApprovalSourceMetadata::Guardian, ) .await; return decision; diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 5282cde5ee..4557cd88e9 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -18,7 +18,6 @@ use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::shell::ShellType; -use crate::state::ApprovalOutcomeMetadata; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; @@ -170,12 +169,10 @@ impl Approvable for ShellRuntime { ) .await; session - .record_call_approval_outcome( - call_id.clone(), - ApprovalOutcomeMetadata::reviewed( - &decision, - ApprovalSourceMetadata::Guardian, - ), + .record_direct_approval_outcome( + &call_id, + &decision, + ApprovalSourceMetadata::Guardian, ) .await; return decision; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index b37966eb86..e18be56d75 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -13,7 +13,6 @@ use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::skills::SkillMetadata; -use crate::state::ApprovalOutcomeMetadata; use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::SandboxAttempt; @@ -441,12 +440,10 @@ impl CoreShellActionProvider { ) .await; session - .record_call_approval_outcome( - call_id.clone(), - ApprovalOutcomeMetadata::reviewed( - &decision, - ApprovalSourceMetadata::Guardian, - ), + .record_direct_approval_outcome( + &call_id, + &decision, + ApprovalSourceMetadata::Guardian, ) .await; return decision; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 2ea5f52123..b444938cd4 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -15,7 +15,6 @@ use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; -use crate::state::ApprovalOutcomeMetadata; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; @@ -139,12 +138,10 @@ impl Approvable for UnifiedExecRuntime<'_> { ) .await; session - .record_call_approval_outcome( - call_id.clone(), - ApprovalOutcomeMetadata::reviewed( - &decision, - ApprovalSourceMetadata::Guardian, - ), + .record_direct_approval_outcome( + &call_id, + &decision, + ApprovalSourceMetadata::Guardian, ) .await; return decision; From f0e39240163c2fe128b93692d7185b2633d47eb8 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 17:43:46 -0700 Subject: [PATCH 19/26] refactor --- codex-rs/core/src/codex.rs | 18 ++--------- codex-rs/core/src/codex_tests.rs | 30 ++++++++++++------- codex-rs/core/src/mcp_tool_call.rs | 8 +++-- .../core/src/tools/runtimes/apply_patch.rs | 11 ++++--- codex-rs/core/src/tools/runtimes/shell.rs | 11 ++++--- .../tools/runtimes/shell/unix_escalation.rs | 25 ++++++++++++---- .../core/src/tools/runtimes/unified_exec.rs | 11 ++++--- 7 files changed, 67 insertions(+), 47 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9ef02e7ea6..53670eba73 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3421,23 +3421,9 @@ impl Session { }); drop(ts); drop(active); - self.record_direct_approval_outcome( - &pending_approval.call_id, - decision, - pending_approval.approval_source, - ) - .await; - } - - pub async fn record_direct_approval_outcome( - &self, - call_id: &str, - decision: &ReviewDecision, - approval_source: ApprovalSourceMetadata, - ) { self.record_call_approval_outcome( - call_id.to_string(), - ApprovalOutcomeMetadata::reviewed(decision, approval_source), + pending_approval.call_id, + ApprovalOutcomeMetadata::reviewed(decision, pending_approval.approval_source), ) .await; } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 376f94c143..1d41be5ed6 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4609,10 +4609,12 @@ async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() { let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; - sess.record_direct_approval_outcome( - "call-guardian-runtime-1", - &ReviewDecision::Denied, - codex_protocol::models::ApprovalSourceMetadata::Guardian, + sess.record_call_approval_outcome( + "call-guardian-runtime-1".to_string(), + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::Guardian, + ), ) .await; sess.record_response_item_and_emit_turn_item( @@ -4654,7 +4656,11 @@ async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_feature_enabled() { let (sess, tc, rx, expected_sandbox_policy) = setup_tool_call_metadata_runtime_test().await; - sess.record_policy_outcome("call-policy-runtime-1").await; + sess.record_call_approval_outcome( + "call-policy-runtime-1".to_string(), + ApprovalOutcomeMetadata::policy(), + ) + .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), function_call_item("call-policy-runtime-1"), @@ -4764,10 +4770,12 @@ async fn tool_call_metadata_can_be_restamped_after_approval_outcome() { }, ) .await; - sess.record_direct_approval_outcome( - "call-restamp-1", - &ReviewDecision::Denied, - codex_protocol::models::ApprovalSourceMetadata::User, + sess.record_call_approval_outcome( + "call-restamp-1".to_string(), + ApprovalOutcomeMetadata::reviewed( + &ReviewDecision::Denied, + codex_protocol::models::ApprovalSourceMetadata::User, + ), ) .await; @@ -4819,7 +4827,7 @@ async fn tool_call_metadata_snapshot_stamps_guardian_approval_source() { let snapshot = ToolApprovalMetadataSnapshot { approval_outcomes_by_call_id: HashMap::from([( "call-guardian-1".to_string(), - crate::state::ApprovalOutcomeMetadata { + ApprovalOutcomeMetadata { review_decision: Some(codex_protocol::models::ReviewDecisionMetadata::Denied), approval_source: codex_protocol::models::ApprovalSourceMetadata::Guardian, }, @@ -4860,7 +4868,7 @@ async fn tool_call_metadata_snapshot_stamps_policy_approval_source_without_revie let snapshot = ToolApprovalMetadataSnapshot { approval_outcomes_by_call_id: HashMap::from([( "call-policy-1".to_string(), - crate::state::ApprovalOutcomeMetadata { + ApprovalOutcomeMetadata { review_decision: None, approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, }, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 8b235d71a5..ac3bd1c5ba 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -32,6 +32,7 @@ use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; +use crate::state::ApprovalOutcomeMetadata; use crate::state_db; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ApprovalSourceMetadata; @@ -533,8 +534,11 @@ async fn maybe_request_mcp_tool_approval( monitor_reason.clone(), ) .await; - sess.record_direct_approval_outcome(call_id, &decision, ApprovalSourceMetadata::Guardian) - .await; + sess.record_call_approval_outcome( + call_id.to_string(), + ApprovalOutcomeMetadata::reviewed(&decision, ApprovalSourceMetadata::Guardian), + ) + .await; let decision = mcp_tool_approval_decision_from_guardian(decision); apply_mcp_tool_approval_decision( sess, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 5a1a8d2888..adbb0ab8a7 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -11,6 +11,7 @@ use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; +use crate::state::ApprovalOutcomeMetadata; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; @@ -143,10 +144,12 @@ impl Approvable for ApplyPatchRuntime { let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); let decision = review_approval_request(session, turn, action, retry_reason).await; session - .record_direct_approval_outcome( - &call_id, - &decision, - ApprovalSourceMetadata::Guardian, + .record_call_approval_outcome( + call_id.clone(), + ApprovalOutcomeMetadata::reviewed( + &decision, + ApprovalSourceMetadata::Guardian, + ), ) .await; return decision; diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 4557cd88e9..5282cde5ee 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -18,6 +18,7 @@ use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::shell::ShellType; +use crate::state::ApprovalOutcomeMetadata; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; @@ -169,10 +170,12 @@ impl Approvable for ShellRuntime { ) .await; session - .record_direct_approval_outcome( - &call_id, - &decision, - ApprovalSourceMetadata::Guardian, + .record_call_approval_outcome( + call_id.clone(), + ApprovalOutcomeMetadata::reviewed( + &decision, + ApprovalSourceMetadata::Guardian, + ), ) .await; return decision; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index e18be56d75..db1de9a596 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -13,6 +13,7 @@ use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::skills::SkillMetadata; +use crate::state::ApprovalOutcomeMetadata; use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::SandboxAttempt; @@ -440,10 +441,12 @@ impl CoreShellActionProvider { ) .await; session - .record_direct_approval_outcome( - &call_id, - &decision, - ApprovalSourceMetadata::Guardian, + .record_call_approval_outcome( + call_id.clone(), + ApprovalOutcomeMetadata::reviewed( + &decision, + ApprovalSourceMetadata::Guardian, + ), ) .await; return decision; @@ -529,14 +532,24 @@ impl CoreShellActionProvider { ) -> anyhow::Result { let action = match decision { Decision::Forbidden => { - self.session.record_policy_outcome(&self.call_id).await; + self.session + .record_call_approval_outcome( + self.call_id.clone(), + ApprovalOutcomeMetadata::policy(), + ) + .await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } Decision::Prompt => { if execve_prompt_is_rejected_by_policy(self.approval_policy, &decision_source) .is_some() { - self.session.record_policy_outcome(&self.call_id).await; + self.session + .record_call_approval_outcome( + self.call_id.clone(), + ApprovalOutcomeMetadata::policy(), + ) + .await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } else { match self diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index b444938cd4..2ea5f52123 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -15,6 +15,7 @@ use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; +use crate::state::ApprovalOutcomeMetadata; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_command_spec; @@ -138,10 +139,12 @@ impl Approvable for UnifiedExecRuntime<'_> { ) .await; session - .record_direct_approval_outcome( - &call_id, - &decision, - ApprovalSourceMetadata::Guardian, + .record_call_approval_outcome( + call_id.clone(), + ApprovalOutcomeMetadata::reviewed( + &decision, + ApprovalSourceMetadata::Guardian, + ), ) .await; return decision; From a4ad4c4166844b1fec4dd24cc6cd22607a87bd91 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 17:55:00 -0700 Subject: [PATCH 20/26] patch network regression --- codex-rs/core/src/codex_tests.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 1d41be5ed6..098f84cc46 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4438,9 +4438,10 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { - host: "example.com".to_string(), - action: NetworkPolicyRuleAction::Allow, + network_policy_amendment: codex_protocol::protocol::NetworkPolicyRule { + protocol: codex_execpolicy::NetworkRuleProtocol::Https, + host_pattern: "example.com".to_string(), + action: codex_protocol::protocol::NetworkPolicyRuleAction::Allow, }, }, codex_protocol::models::ApprovalSourceMetadata::User, @@ -4451,9 +4452,10 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { - host: "example.com".to_string(), - action: NetworkPolicyRuleAction::Deny, + network_policy_amendment: codex_protocol::protocol::NetworkPolicyRule { + protocol: codex_execpolicy::NetworkRuleProtocol::Https, + host_pattern: "example.com".to_string(), + action: codex_protocol::protocol::NetworkPolicyRuleAction::Deny, }, }, codex_protocol::models::ApprovalSourceMetadata::User, From 95c7d03161959ba9d12f5a78f2c01a56f5efc10b Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 18:06:36 -0700 Subject: [PATCH 21/26] tests --- codex-rs/core/src/codex_tests.rs | 38 ++++++++------------------------ 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 098f84cc46..4db75c5ac5 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4621,37 +4621,17 @@ async fn tool_call_metadata_stamps_guardian_direct_review_when_feature_enabled() .await; sess.record_response_item_and_emit_turn_item( tc.as_ref(), - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-guardian-runtime-1".to_string(), - metadata: None, - }, + function_call_item("call-guardian-runtime-1"), + ) + .await; + assert_next_emitted_function_call_metadata( + &rx, + expected_sandbox_policy, + true, + Some(codex_protocol::models::ReviewDecisionMetadata::Denied), + Some(codex_protocol::models::ApprovalSourceMetadata::Guardian), ) .await; - - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("expected raw response item event") - .expect("channel open"); - assert!(matches!( - event.msg, - EventMsg::RawResponseItem(ref ev) - if matches!( - &ev.item, - ResponseItem::FunctionCall { - metadata: Some(metadata), - .. - } if metadata.is_tool_call_escalated == Some(true) - && metadata.review_decision - == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) - && metadata.approval_source - == Some(codex_protocol::models::ApprovalSourceMetadata::Guardian) - && metadata.sandbox_policy == Some(expected_sandbox_policy) - ) - )); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From a411d51c88698c8459d192d2066ab56d7b367283 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 18 Mar 2026 18:14:31 -0700 Subject: [PATCH 22/26] protocol types --- codex-rs/core/src/codex_tests.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4db75c5ac5..992e5c07da 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4438,10 +4438,9 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::protocol::NetworkPolicyRule { - protocol: codex_execpolicy::NetworkRuleProtocol::Https, - host_pattern: "example.com".to_string(), - action: codex_protocol::protocol::NetworkPolicyRuleAction::Allow, + network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Allow, }, }, codex_protocol::models::ApprovalSourceMetadata::User, @@ -4452,10 +4451,9 @@ fn review_decision_metadata_mapping_is_stable() { assert_eq!( ApprovalOutcomeMetadata::reviewed( &ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment: codex_protocol::protocol::NetworkPolicyRule { - protocol: codex_execpolicy::NetworkRuleProtocol::Https, - host_pattern: "example.com".to_string(), - action: codex_protocol::protocol::NetworkPolicyRuleAction::Deny, + network_policy_amendment: codex_protocol::approvals::NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Deny, }, }, codex_protocol::models::ApprovalSourceMetadata::User, From a7de20f2fdcd10d2ab4eaade1acaa036c04b2b78 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 18:39:40 -0700 Subject: [PATCH 23/26] Remove unused approval and pending-input wrappers --- codex-rs/core/src/codex.rs | 30 ------------------------------ codex-rs/core/src/state/turn.rs | 9 --------- 2 files changed, 39 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 53670eba73..1cae2f7836 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -208,12 +208,6 @@ use crate::file_watcher::FileWatcher; use crate::file_watcher::FileWatcherEvent; use crate::git_info::get_git_repo_root; use crate::guardian::GuardianReviewSessionManager; -use crate::hook_runtime::PendingInputHookDisposition; -use crate::hook_runtime::inspect_pending_input; -use crate::hook_runtime::record_additional_contexts; -use crate::hook_runtime::record_pending_input; -use crate::hook_runtime::run_pending_session_start_hooks; -use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::inspect_pending_input; @@ -3427,11 +3421,6 @@ impl Session { ) .await; } - - pub async fn record_policy_outcome(&self, call_id: &str) { - self.record_call_approval_outcome(call_id.to_string(), ApprovalOutcomeMetadata::policy()) - .await; - } pub(crate) async fn record_call_approval_outcome( &self, call_id: String, @@ -4274,25 +4263,6 @@ impl Session { } } - pub(crate) async fn prepend_pending_input_with_metadata( - &self, - input: Vec<(ResponseInputItem, Option)>, - ) -> Result<(), ()> { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - ts.prepend_pending_input( - input - .into_iter() - .map(|(input, metadata)| crate::state::PendingInputItem { input, metadata }) - .collect(), - ); - Ok(()) - } - None => Err(()), - } - } pub async fn has_pending_input(&self) -> bool { let active = self.active_turn.lock().await; match active.as_ref() { diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index e4b4c1c9ed..7d4d4eb9c4 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -303,15 +303,6 @@ impl TurnState { } } - pub(crate) fn prepend_pending_input(&mut self, mut input: Vec) { - if input.is_empty() { - return; - } - - input.append(&mut self.pending_input); - self.pending_input = input; - } - pub(crate) fn has_pending_input(&self) -> bool { !self.pending_input.is_empty() } From cf70384367dc3dbd5e05c35c460e9d2b4387fe87 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 19:03:28 -0700 Subject: [PATCH 24/26] lint --- codex-rs/app-server/src/bespoke_event_handling.rs | 3 --- codex-rs/core/src/codex_tests.rs | 6 +++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 00be6ef686..26e0e8bb16 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2815,11 +2815,8 @@ mod tests { use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; - use codex_protocol::models::ContentItem; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; - use codex_protocol::models::ResponseItem; - use codex_protocol::models::ResponseItemMetadata; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::protocol::CollabResumeBeginEvent; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 992e5c07da..46122b953d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -5065,7 +5065,11 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { let mut drained_iter = drained.into_iter(); let _blocked = drained_iter.next().expect("blocked prompt should exist"); - sess.prepend_pending_input_with_metadata(drained_iter.collect()) + sess.prepend_pending_input_with_metadata( + drained_iter + .map(|(input, metadata)| crate::state::PendingInputItem { input, metadata }) + .collect(), + ) .await .expect("requeue later pending input at the front of the queue"); From cb6ace9d1b28e7e8d89d07d913b73634ae07a205 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 21:21:59 -0700 Subject: [PATCH 25/26] clean up --- codex-rs/core/src/codex_tests.rs | 9 ++++++--- codex-rs/core/src/state/turn.rs | 7 ------- .../core/src/tools/runtimes/shell/unix_escalation.rs | 10 ++++++++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 46122b953d..0f6c71f299 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -4638,7 +4638,10 @@ async fn tool_call_metadata_stamps_policy_source_without_review_decision_when_fe sess.record_call_approval_outcome( "call-policy-runtime-1".to_string(), - ApprovalOutcomeMetadata::policy(), + ApprovalOutcomeMetadata { + review_decision: None, + approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, + }, ) .await; sess.record_response_item_and_emit_turn_item( @@ -5070,8 +5073,8 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { .map(|(input, metadata)| crate::state::PendingInputItem { input, metadata }) .collect(), ) - .await - .expect("requeue later pending input at the front of the queue"); + .await + .expect("requeue later pending input at the front of the queue"); assert_eq!( sess.get_pending_input_with_metadata().await, diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 7d4d4eb9c4..e482924cef 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -107,13 +107,6 @@ impl ApprovalOutcomeMetadata { approval_source, } } - - pub(crate) fn policy() -> Self { - Self { - review_decision: None, - approval_source: ApprovalSourceMetadata::Policy, - } - } } impl ActiveTurn { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index db1de9a596..e0e5a90e31 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -535,7 +535,10 @@ impl CoreShellActionProvider { self.session .record_call_approval_outcome( self.call_id.clone(), - ApprovalOutcomeMetadata::policy(), + ApprovalOutcomeMetadata { + review_decision: None, + approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, + }, ) .await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) @@ -547,7 +550,10 @@ impl CoreShellActionProvider { self.session .record_call_approval_outcome( self.call_id.clone(), - ApprovalOutcomeMetadata::policy(), + ApprovalOutcomeMetadata { + review_decision: None, + approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, + }, ) .await; EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) From d5d70d39f7e4fe44c7a3f99227f2c134608c98ca Mon Sep 17 00:00:00 2001 From: Roy Han Date: Thu, 19 Mar 2026 21:38:18 -0700 Subject: [PATCH 26/26] lint --- codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index e0e5a90e31..69bcaedc58 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -552,7 +552,8 @@ impl CoreShellActionProvider { self.call_id.clone(), ApprovalOutcomeMetadata { review_decision: None, - approval_source: codex_protocol::models::ApprovalSourceMetadata::Policy, + approval_source: + codex_protocol::models::ApprovalSourceMetadata::Policy, }, ) .await;