diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 351671b971..8a56ded449 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2032,6 +2032,16 @@ } ] }, + "sandbox_policy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicyMetadata" + }, + { + "type": "null" + } + ] + }, "user_message_type": { "anyOf": [ { @@ -2410,6 +2420,14 @@ } ] }, + "SandboxPolicyMetadata": { + "enum": [ + "read_only", + "sandbox", + "full_access" + ], + "type": "string" + }, "ServiceTier": { "enum": [ "fast", 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 cfa709f9c6..f7a426ac06 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 @@ -10661,6 +10661,16 @@ } ] }, + "sandbox_policy": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SandboxPolicyMetadata" + }, + { + "type": "null" + } + ] + }, "user_message_type": { "anyOf": [ { @@ -11059,6 +11069,14 @@ } ] }, + "SandboxPolicyMetadata": { + "enum": [ + "read_only", + "sandbox", + "full_access" + ], + "type": "string" + }, "SandboxWorkspaceWrite": { "properties": { "exclude_slash_tmp": { 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 5771b59834..9d5de29bc5 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 @@ -7406,6 +7406,16 @@ } ] }, + "sandbox_policy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicyMetadata" + }, + { + "type": "null" + } + ] + }, "user_message_type": { "anyOf": [ { @@ -7804,6 +7814,14 @@ } ] }, + "SandboxPolicyMetadata": { + "enum": [ + "read_only", + "sandbox", + "full_access" + ], + "type": "string" + }, "SandboxWorkspaceWrite": { "properties": { "exclude_slash_tmp": { 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 b128ea792c..d6dcead1c0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -873,6 +873,16 @@ } ] }, + "sandbox_policy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicyMetadata" + }, + { + "type": "null" + } + ] + }, "user_message_type": { "anyOf": [ { @@ -999,6 +1009,14 @@ ], "type": "string" }, + "SandboxPolicyMetadata": { + "enum": [ + "read_only", + "sandbox", + "full_access" + ], + "type": "string" + }, "UserMessageType": { "enum": [ "prompt", 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 538ae98e8f..1e3ad1d66c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -939,6 +939,16 @@ } ] }, + "sandbox_policy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicyMetadata" + }, + { + "type": "null" + } + ] + }, "user_message_type": { "anyOf": [ { @@ -1073,6 +1083,14 @@ ], "type": "string" }, + "SandboxPolicyMetadata": { + "enum": [ + "read_only", + "sandbox", + "full_access" + ], + "type": "string" + }, "ServiceTier": { "enum": [ "fast", diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts index 7e90e272f7..4f3ffa0405 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ReviewDecisionMetadata } from "./ReviewDecisionMetadata"; +import type { SandboxPolicyMetadata } from "./SandboxPolicyMetadata"; import type { UserMessageType } from "./UserMessageType"; -export type ResponseItemMetadata = { user_message_type?: UserMessageType, is_tool_call_escalated?: boolean, review_decision?: ReviewDecisionMetadata, }; +export type ResponseItemMetadata = { user_message_type?: UserMessageType, sandbox_policy?: SandboxPolicyMetadata, is_tool_call_escalated?: boolean, review_decision?: ReviewDecisionMetadata, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SandboxPolicyMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/SandboxPolicyMetadata.ts new file mode 100644 index 0000000000..f11e2b4931 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SandboxPolicyMetadata.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 SandboxPolicyMetadata = "read_only" | "sandbox" | "full_access"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 39c91346f3..7befcf42c1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -61,6 +61,7 @@ export type { ResponseItem } from "./ResponseItem"; export type { ResponseItemMetadata } from "./ResponseItemMetadata"; export type { ReviewDecision } from "./ReviewDecision"; export type { ReviewDecisionMetadata } from "./ReviewDecisionMetadata"; +export type { SandboxPolicyMetadata } from "./SandboxPolicyMetadata"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; export type { ServiceTier } from "./ServiceTier"; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0e01a1ee3b..0368bd2b69 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -323,6 +323,7 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::models::ResponseItemMetadata; use codex_protocol::models::ReviewDecisionMetadata; +use codex_protocol::models::SandboxPolicyMetadata; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; @@ -1012,6 +1013,31 @@ fn stamp_user_message_type_on_input_item(item: &mut ResponseInputItem, kind: Use *metadata = Some(metadata_value); } +fn sandbox_policy_to_metadata(policy: &SandboxPolicy) -> SandboxPolicyMetadata { + match policy { + SandboxPolicy::ReadOnly { .. } => SandboxPolicyMetadata::ReadOnly, + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::ExternalSandbox { .. } => { + SandboxPolicyMetadata::Sandbox + } + SandboxPolicy::DangerFullAccess => SandboxPolicyMetadata::FullAccess, + } +} + +fn stamp_sandbox_policy_on_input_item( + item: &mut ResponseInputItem, + sandbox_policy: SandboxPolicyMetadata, +) { + let ResponseInputItem::Message { role, metadata, .. } = item else { + return; + }; + if role != "user" { + return; + } + let mut metadata_value = metadata.take().unwrap_or_default(); + metadata_value.sandbox_policy = Some(sandbox_policy); + *metadata = Some(metadata_value); +} + fn review_decision_to_metadata(decision: &ReviewDecision) -> ReviewDecisionMetadata { match decision { ReviewDecision::Approved => ReviewDecisionMetadata::Approved, @@ -3383,7 +3409,7 @@ impl Session { async fn stamp_tool_approval_metadata( &self, - _turn_context: &TurnContext, + turn_context: &TurnContext, response_item: ResponseItem, ) -> ResponseItem { if !self.enabled(Feature::ItemMetadata) { @@ -3410,6 +3436,9 @@ impl Session { | ResponseItem::CustomToolCall { metadata, .. } => metadata.clone().unwrap_or_default(), _ => return response_item, }; + metadata.sandbox_policy = Some(sandbox_policy_to_metadata( + turn_context.sandbox_policy.get(), + )); match outcome { Some(review_decision) => { @@ -3421,7 +3450,7 @@ impl Session { metadata.review_decision = None; } None => { - return response_item; + return stamp_tool_metadata_on_response_item(response_item, metadata); } } @@ -3944,13 +3973,18 @@ impl Session { response_item: ResponseItem, user_message_type: Option, ) { - let user_message_type = if self.enabled(Feature::ItemMetadata) { - user_message_type + let (user_message_type, sandbox_policy) = if self.enabled(Feature::ItemMetadata) { + ( + user_message_type, + Some(sandbox_policy_to_metadata( + turn_context.sandbox_policy.get(), + )), + ) } else { - None + (None, None) }; - let response_item = match (response_item, user_message_type.clone()) { + let response_item = match (response_item, user_message_type.clone(), sandbox_policy) { ( ResponseItem::Message { id, @@ -3960,10 +3994,12 @@ impl Session { end_turn, phase, }, - Some(kind), + user_message_type, + sandbox_policy, ) if role == "user" => { let mut metadata = metadata.unwrap_or_default(); - metadata.user_message_type = Some(kind); + metadata.user_message_type = user_message_type; + metadata.sandbox_policy = sandbox_policy; ResponseItem::Message { id, role, @@ -3973,7 +4009,7 @@ impl Session { phase, } } - (response_item, _) => response_item, + (response_item, _, _) => response_item, }; // Persist the user message to history, but emit the turn item from `UserInput` so @@ -4061,7 +4097,7 @@ impl Session { return Err(SteerInputError::NoActiveTurn(input)); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { + let Some((active_turn_id, active_task)) = active_turn.tasks.first() else { return Err(SteerInputError::NoActiveTurn(input)); }; @@ -4070,18 +4106,22 @@ impl Session { { return Err(SteerInputError::ExpectedTurnMismatch { expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + actual: active_turn_id.to_string(), }); } let mut input_item: ResponseInputItem = input.into(); if self.enabled(Feature::ItemMetadata) { stamp_user_message_type_on_input_item(&mut input_item, UserMessageType::PromptSteering); + stamp_sandbox_policy_on_input_item( + &mut input_item, + sandbox_policy_to_metadata(active_task.turn_context.sandbox_policy.get()), + ); } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input_item, Some(UserMessageType::PromptSteering)); - Ok(active_turn_id.clone()) + Ok(active_turn_id.to_string()) } /// Returns the input if there was no task running to inject into @@ -4092,6 +4132,9 @@ impl Session { let mut active = self.active_turn.lock().await; match active.as_mut() { Some(at) => { + let sandbox_policy = at.tasks.first().map(|(_, turn_context)| { + sandbox_policy_to_metadata(turn_context.turn_context.sandbox_policy.get()) + }); let mut ts = at.turn_state.lock().await; for mut item in input { let user_message_type = match &item { @@ -4102,6 +4145,9 @@ impl Session { && let Some(kind) = user_message_type.clone() { stamp_user_message_type_on_input_item(&mut item, kind); + if let Some(sandbox_policy) = sandbox_policy.clone() { + stamp_sandbox_policy_on_input_item(&mut item, sandbox_policy); + } } ts.push_pending_input(item, user_message_type); } @@ -5887,6 +5933,10 @@ pub(crate) async fn run_turn( let mut initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); if sess.enabled(Feature::ItemMetadata) { stamp_user_message_type_on_input_item(&mut initial_input_for_turn, UserMessageType::Prompt); + stamp_sandbox_policy_on_input_item( + &mut initial_input_for_turn, + sandbox_policy_to_metadata(turn_context.sandbox_policy.get()), + ); } let response_item: ResponseItem = initial_input_for_turn.clone().into(); sess.record_user_prompt_and_emit_turn_item( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index c3acaf981c..56b828e577 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3940,9 +3940,26 @@ fn review_decision_metadata_mapping_is_stable() { ); } +#[test] +fn sandbox_policy_metadata_mapping_is_stable() { + assert_eq!( + sandbox_policy_to_metadata(&SandboxPolicy::DangerFullAccess), + codex_protocol::models::SandboxPolicyMetadata::FullAccess + ); + assert_eq!( + sandbox_policy_to_metadata(&SandboxPolicy::new_read_only_policy()), + codex_protocol::models::SandboxPolicyMetadata::ReadOnly + ); + assert_eq!( + sandbox_policy_to_metadata(&SandboxPolicy::new_workspace_write_policy()), + codex_protocol::models::SandboxPolicyMetadata::Sandbox + ); +} + #[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) .expect("session should be uniquely owned in this test") .features @@ -3993,6 +4010,7 @@ async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enable } if metadata.is_tool_call_escalated == Some(true) && metadata.review_decision == Some(codex_protocol::models::ReviewDecisionMetadata::Denied) + && metadata.sandbox_policy == Some(expected_sandbox_policy) ) )); } @@ -4000,6 +4018,7 @@ async fn tool_call_metadata_stamps_escalated_review_decision_when_feature_enable #[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 @@ -4047,6 +4066,7 @@ async fn tool_call_metadata_stamps_non_escalated_false_when_feature_enabled() { .. } if metadata.is_tool_call_escalated == Some(false) && metadata.review_decision.is_none() + && metadata.sandbox_policy == Some(expected_sandbox_policy) ) )); } diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 90fe3a236d..243ed86ba2 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -344,6 +344,13 @@ async fn user_message_type_prompt_steering_metadata_is_emitted_when_feature_enab .and_then(Value::as_str), Some("prompt_steering") ); + assert_eq!( + steered_message + .get("metadata") + .and_then(|metadata| metadata.get("sandbox_policy")) + .and_then(Value::as_str), + Some("full_access") + ); Ok(()) } @@ -476,6 +483,13 @@ async fn user_message_type_prompt_queued_metadata_is_emitted_when_feature_enable .and_then(Value::as_str), Some("prompt_queued") ); + assert_eq!( + queued_message + .get("metadata") + .and_then(|metadata| metadata.get("sandbox_policy")) + .and_then(Value::as_str), + Some("full_access") + ); Ok(()) } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index c455b0e99d..95d3f3ef81 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -275,6 +275,15 @@ pub enum ReviewDecisionMetadata { DeniedWithNetworkPolicyDeny, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum SandboxPolicyMetadata { + ReadOnly, + Sandbox, + FullAccess, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] pub struct ResponseItemMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -282,6 +291,9 @@ pub struct ResponseItemMetadata { pub user_message_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub sandbox_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub is_tool_call_escalated: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)]