From 401059df750667d2af06eb2d64e9f59f34c11471 Mon Sep 17 00:00:00 2001 From: won Date: Wed, 10 Jun 2026 11:59:07 -0700 Subject: [PATCH] draft --- .../src/tools/handlers/extension_tools.rs | 71 +++++++++++++++++++ codex-rs/ext/extension-api/src/lib.rs | 2 + .../ext/goal/tests/goal_extension_backend.rs | 1 + codex-rs/ext/image-generation/src/tests.rs | 7 +- codex-rs/ext/image-generation/src/tool.rs | 44 ++++++++++-- codex-rs/ext/memories/src/tests.rs | 6 ++ codex-rs/tools/src/lib.rs | 2 + codex-rs/tools/src/tool_call.rs | 16 +++++ 8 files changed, 144 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index ecbf772e90..af595cfdfc 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,15 +1,20 @@ use std::sync::Arc; use std::sync::Weak; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::FileSystemSandboxContext; use codex_protocol::items::TurnItem; use codex_tools::ConversationHistory; use codex_tools::ExtensionTurnItem; use codex_tools::ToolCall as ExtensionToolCall; +use codex_tools::ToolFileReadFuture; +use codex_tools::ToolFileReader; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use codex_tools::TurnItemEmissionFuture; use codex_tools::TurnItemEmitter; +use codex_utils_absolute_path::AbsolutePathBuf; use crate::function_tool::FunctionCallError; use crate::session::session::Session; @@ -71,6 +76,17 @@ struct CoreTurnItemEmitter { turn: Weak, } +struct CoreToolFileReader { + file_system: Arc, + sandbox: FileSystemSandboxContext, +} + +impl ToolFileReader for CoreToolFileReader { + fn read_file<'a>(&'a self, path: &'a AbsolutePathBuf) -> ToolFileReadFuture<'a> { + self.file_system.read_file(path, Some(&self.sandbox)) + } +} + fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem { match item { ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item), @@ -115,6 +131,15 @@ impl TurnItemEmitter for CoreTurnItemEmitter { async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { let conversation_history = ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); + let file_reader = invocation.turn.environments.primary().map(|environment| { + Arc::new(CoreToolFileReader { + file_system: environment.environment.get_filesystem(), + sandbox: invocation.turn.file_system_sandbox_context( + /*additional_permissions*/ None, + &environment.cwd, + ), + }) as Arc + }); ExtensionToolCall { turn_id: invocation.turn.sub_id.clone(), call_id: invocation.call_id.clone(), @@ -126,6 +151,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { session: Arc::downgrade(&invocation.session), turn: Arc::downgrade(&invocation.turn), }), + file_reader, payload: invocation.payload.clone(), } } @@ -139,16 +165,24 @@ mod tests { use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; + use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::EventMsg; use codex_tools::ExtensionTurnItem; + use codex_tools::ToolFileReader as _; use codex_utils_absolute_path::test_support::PathExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::Mutex; + use super::CoreToolFileReader; use super::CoreTurnItemEmitter; use super::ExtensionToolAdapter; use crate::tools::context::ToolCallSource; @@ -275,6 +309,42 @@ mod tests { ); } + #[tokio::test] + async fn file_system_capability_enforces_turn_sandbox() { + let (_session, mut turn) = crate::session::tests::make_session_and_context().await; + let workspace = tempfile::tempdir().expect("create temp directory"); + let cwd = workspace.path().abs(); + let denied_path = cwd.join("denied.png"); + std::fs::write(&denied_path, b"image bytes").expect("write denied file"); + turn.environments.turn_environments[0].cwd = cwd.clone(); + + let mut file_system_policy = FileSystemSandboxPolicy::default(); + file_system_policy.entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_path.clone(), + }, + access: FileSystemAccessMode::Deny, + }); + turn.permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); + + let environment = turn.environments.primary().expect("primary environment"); + let file_reader = CoreToolFileReader { + file_system: environment.environment.get_filesystem(), + sandbox: turn.file_system_sandbox_context( + /*additional_permissions*/ None, + &environment.cwd, + ), + }; + + file_reader + .read_file(&denied_path) + .await + .expect_err("sandbox should reject denied file"); + } + #[tokio::test] async fn passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call() { let captured_call = Arc::new(Mutex::new(None)); @@ -331,6 +401,7 @@ mod tests { ); assert_eq!(captured_call.model, model); assert_eq!(captured_call.truncation_policy, truncation_policy); + assert!(captured_call.file_reader.is_some()); assert_eq!( captured_call.conversation_history.items(), std::slice::from_ref(&history_item) diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 7fa60c0fe7..2ec4f466c5 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -20,6 +20,8 @@ pub use codex_tools::NoopTurnItemEmitter; pub use codex_tools::ResponsesApiTool; pub use codex_tools::ToolCall; pub use codex_tools::ToolExecutor; +pub use codex_tools::ToolFileReadFuture; +pub use codex_tools::ToolFileReader; pub use codex_tools::ToolName; pub use codex_tools::ToolOutput; pub use codex_tools::ToolPayload; diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index f89a08ebbb..4843874112 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -1317,6 +1317,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), + file_reader: None, payload: ToolPayload::Function { arguments: arguments.to_string(), }, diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 3f72972656..c71dc3d5f4 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -46,7 +46,8 @@ fn omitted_references_generate_with_fixed_defaults() { referenced_image_paths: None, num_last_images_to_include: None, }, - &[] + &[], + /*referenced_images*/ None, ) .expect("generation request should build"), ImageRequest::Generate(ImageGenerationRequest { @@ -118,6 +119,7 @@ fn recent_image_fallback_selects_newest_images_in_chronological_order() { num_last_images_to_include: Some(4), }, &history, + /*referenced_images*/ None, ) .expect("history-backed edit request should build"), ImageRequest::Edit(expected_edit_request( @@ -140,6 +142,7 @@ fn conflicting_image_selectors_return_tool_error() { num_last_images_to_include: Some(1), }, &[], + /*referenced_images*/ None, ) .expect_err("conflicting selectors should fail"); @@ -166,6 +169,7 @@ fn too_many_referenced_image_paths_return_tool_error() { num_last_images_to_include: None, }, &[], + /*referenced_images*/ None, ) .expect_err("too many paths should fail before reading files"); @@ -189,6 +193,7 @@ fn recent_image_fallback_requires_requested_count() { content: vec![input_image("only-image")], phase: None, }], + /*referenced_images*/ None, ) .expect_err("history-backed edit should require the requested image count"); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 69b99bdbc4..ff3e7b2b92 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -11,6 +11,7 @@ use codex_extension_api::ExtensionTurnItem; use codex_extension_api::FunctionCallError; use codex_extension_api::ToolCall; use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolFileReader; use codex_extension_api::ToolName; use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; @@ -98,7 +99,9 @@ impl ToolExecutor for ImageGenerationTool { /// Executes the selected image operation and returns the completed image result. async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { let args = parse_args(&call)?; - let request = request_for_args(&args, call.conversation_history.items())?; + let referenced_images = load_referenced_images(&args, call.file_reader.as_deref()).await?; + let request = + request_for_args(&args, call.conversation_history.items(), referenced_images)?; call.turn_item_emitter .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { id: call.call_id.clone(), @@ -149,10 +152,36 @@ enum ImageRequest { Edit(ImageEditRequest), } +async fn load_referenced_images( + args: &ImagegenArgs, + file_reader: Option<&dyn ToolFileReader>, +) -> Result>, FunctionCallError> { + let paths = args.referenced_image_paths.as_deref().unwrap_or_default(); + if paths.is_empty() + || args.num_last_images_to_include.is_some() + || paths.len() > MAX_EDIT_IMAGES + { + return Ok(None); + } + + let Some(file_reader) = file_reader else { + return Err(FunctionCallError::RespondToModel( + "referenced image paths are unavailable in this session".to_string(), + )); + }; + let mut referenced_images = Vec::with_capacity(paths.len()); + for path in paths { + referenced_images.push(image_url(path, file_reader).await?); + } + + Ok(Some(referenced_images)) +} + /// Builds a generation or edit request from the mutually exclusive image selectors. fn request_for_args( args: &ImagegenArgs, history: &[ResponseItem], + referenced_images: Option>, ) -> Result { let paths = args.referenced_image_paths.as_deref().unwrap_or_default(); if paths.len() > MAX_EDIT_IMAGES { @@ -171,7 +200,11 @@ fn request_for_args( size: Some("auto".to_string()), })); } - (false, None) => paths.iter().map(image_url).collect::, _>>()?, + (false, None) => referenced_images.ok_or_else(|| { + FunctionCallError::RespondToModel( + "referenced image paths are unavailable in this session".to_string(), + ) + })?, (true, Some(count)) => { if !(1..=MAX_EDIT_IMAGES).contains(&count) { return Err(FunctionCallError::RespondToModel(format!( @@ -302,8 +335,11 @@ fn output_image_urls(output: &FunctionCallOutputPayload) -> impl Iterator Result { - let bytes = std::fs::read(path).map_err(|error| { +async fn image_url( + path: &AbsolutePathBuf, + file_reader: &dyn ToolFileReader, +) -> Result { + let bytes = file_reader.read_file(path).await.map_err(|error| { FunctionCallError::RespondToModel(format!( "unable to read referenced image at `{}`: {error}", path.display() diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index ce03d74965..68581e3416 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -215,6 +215,7 @@ async fn add_ad_hoc_note_tool_creates_note_file() { truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), + file_reader: None, payload: payload.clone(), }) .await @@ -258,6 +259,7 @@ async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() { truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), + file_reader: None, payload, }) .await; @@ -302,6 +304,7 @@ async fn read_tool_reads_memory_file() { truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), + file_reader: None, payload: payload.clone(), }) .await @@ -349,6 +352,7 @@ async fn search_tool_accepts_multiple_queries() { truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), + file_reader: None, payload: payload.clone(), }) .await @@ -422,6 +426,7 @@ async fn search_tool_accepts_windowed_all_match_mode() { truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), + file_reader: None, payload: payload.clone(), }) .await @@ -475,6 +480,7 @@ async fn search_tool_rejects_legacy_single_query() { truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), + file_reader: None, payload, }) .await; diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 1cee3f7d2d..28c3d0c47b 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -66,6 +66,8 @@ pub use tool_call::ConversationHistory; pub use tool_call::ExtensionTurnItem; pub use tool_call::NoopTurnItemEmitter; pub use tool_call::ToolCall; +pub use tool_call::ToolFileReadFuture; +pub use tool_call::ToolFileReader; pub use tool_call::TurnItemEmissionFuture; pub use tool_call::TurnItemEmitter; pub use tool_config::ShellCommandBackendConfig; diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index e3fc21e864..95796b0d13 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -4,8 +4,10 @@ use crate::ToolPayload; use codex_protocol::items::ImageGenerationItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use std::future::Future; +use std::io; use std::pin::Pin; use std::sync::Arc; @@ -63,6 +65,15 @@ impl TurnItemEmitter for NoopTurnItemEmitter { } } +/// Future returned when an extension tool reads a file through the host. +pub type ToolFileReadFuture<'a> = Pin>> + Send + 'a>>; + +/// Host-provided sandboxed file reads for extension tools. +pub trait ToolFileReader: Send + Sync { + /// Reads one file through host-owned sandbox enforcement. + fn read_file<'a>(&'a self, path: &'a AbsolutePathBuf) -> ToolFileReadFuture<'a>; +} + #[derive(Clone)] pub struct ToolCall { pub turn_id: String, @@ -72,6 +83,7 @@ pub struct ToolCall { pub truncation_policy: TruncationPolicy, pub conversation_history: ConversationHistory, pub turn_item_emitter: Arc, + pub file_reader: Option>, pub payload: ToolPayload, } @@ -85,6 +97,10 @@ impl std::fmt::Debug for ToolCall { .field("truncation_policy", &self.truncation_policy) .field("conversation_history", &self.conversation_history) .field("turn_item_emitter", &"") + .field( + "file_reader", + &self.file_reader.as_ref().map(|_| ""), + ) .field("payload", &self.payload) .finish() }