From 57e2edc6e97474448f1fb634224471448bc09d40 Mon Sep 17 00:00:00 2001 From: pmccrary-oai Date: Thu, 27 Aug 2026 03:53:42 +0000 Subject: [PATCH] Encrypt sensitive history and notes tool arguments (#41041) ## What changed - Mark history and notes search queries, appended note text, and replacement note text as encrypted in their tool schemas. - Send `x-openai-encrypted-tool-arguments: true` for the corresponding backend routes without changing the JSON request body. ## Testing - Cover encrypted schema fields and backend headers for history search, notes search, append, and write calls. - Verify unrelated history and notes requests do not receive the encrypted-arguments header. GitOrigin-RevId: 58263cff832c979b436ddb4013b5c27218aca678 --- .../tests/suite/v2/history_notes_extension.rs | 26 ++++++-- codex-rs/ext/history-notes/src/backend.rs | 14 +++++ .../ext/history-notes/src/backend_tests.rs | 61 +++++++++++++++++++ codex-rs/ext/history-notes/src/tools.rs | 8 +-- 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/history_notes_extension.rs b/codex-rs/app-server/tests/suite/v2/history_notes_extension.rs index 6c122d5bf9..d49545f70d 100644 --- a/codex-rs/app-server/tests/suite/v2/history_notes_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/history_notes_extension.rs @@ -22,6 +22,7 @@ use tokio::time::timeout; use wiremock::Mock; use wiremock::Request; use wiremock::ResponseTemplate; +use wiremock::matchers::body_partial_json; use wiremock::matchers::method; use wiremock::matchers::path; @@ -399,6 +400,7 @@ async fn guardian_can_read_parent_history_without_inheriting_notes_tools() -> Re #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result<()> { + let encrypted_query = format!("enc_query_{}", "x".repeat(1_001)); let calls = [ ("history", "list_windows", json!({})), ("history", "list_items", json!({})), @@ -410,7 +412,7 @@ async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result ( "history", "search_contents", - json!({"query": "PRIVATE_QUERY"}), + json!({"query": encrypted_query, "limit": 2, "recent_first": false}), ), ( "notes", @@ -421,17 +423,17 @@ async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result ( "notes", "search_contents", - json!({"query": "PRIVATE_QUERY"}), + json!({"query": encrypted_query, "max_files": 2}), ), ( "notes", "append_to_file", - json!({"path": "PRIVATE_PATH", "text": "PRIVATE_TEXT"}), + json!({"path": "PRIVATE_PATH", "text": "enc_append_text"}), ), ( "notes", "write_file", - json!({"path": "PRIVATE_PATH", "text": "PRIVATE_TEXT"}), + json!({"path": "PRIVATE_PATH", "text": "enc_write_text"}), ), ( "functions", @@ -450,11 +452,12 @@ async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result ), ]; let server = responses::start_mock_server().await; - for (namespace, tool, _) in &calls[..9] { + for (namespace, tool, arguments) in &calls[..9] { Mock::given(method("POST")) .and(path(format!( "/backend-api/codex/alpha/{namespace}/v2/{tool}" ))) + .and(body_partial_json(arguments.clone())) .respond_with( ResponseTemplate::new(200).set_body_json(json!({"text": "PRIVATE_RESULT"})), ) @@ -530,6 +533,19 @@ async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result }) .await?; + let request = &response_mock.requests()[0]; + for (namespace, name, field) in [ + ("history", "search_contents", "query"), + ("notes", "search_contents", "query"), + ("notes", "append_to_file", "text"), + ("notes", "write_file", "text"), + ] { + let tool = request + .tool_by_name(namespace, name) + .expect("declared history or notes tool"); + assert_eq!(tool["parameters"]["properties"][field]["encrypted"], true); + } + for (index, (namespace, tool, _)) in calls.iter().enumerate() { let event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| { event["event_type"] == "codex_control_tool_call_event" diff --git a/codex-rs/ext/history-notes/src/backend.rs b/codex-rs/ext/history-notes/src/backend.rs index 060e6070d3..d874da7d5f 100644 --- a/codex-rs/ext/history-notes/src/backend.rs +++ b/codex-rs/ext/history-notes/src/backend.rs @@ -5,11 +5,13 @@ use codex_client::HttpTransport; use codex_client::RequestBody; use codex_login::default_client::create_client; use codex_model_provider::SharedModelProvider; +use http::HeaderValue; use http::Method; use serde_json::Value; use serde_json::json; const HISTORY_NOTES_BACKEND_TIMEOUT: Duration = Duration::from_secs(35); +const ENCRYPTED_TOOL_ARGUMENTS_HEADER: &str = "x-openai-encrypted-tool-arguments"; #[derive(Clone)] pub(crate) struct HistoryNotesBackend { @@ -50,6 +52,18 @@ impl HistoryNotesBackend { .map_err(|error| format!("History backend auth could not be resolved: {error}"))?; let mut request = provider.build_request(Method::POST, path); + if matches!( + path, + "alpha/history/v2/search_contents" + | "alpha/notes/v2/search_contents" + | "alpha/notes/v2/append_to_file" + | "alpha/notes/v2/write_file" + ) { + request.headers.insert( + ENCRYPTED_TOOL_ARGUMENTS_HEADER, + HeaderValue::from_static("true"), + ); + } request.body = Some(RequestBody::Json(arguments)); request.timeout = Some(HISTORY_NOTES_BACKEND_TIMEOUT); let request = auth diff --git a/codex-rs/ext/history-notes/src/backend_tests.rs b/codex-rs/ext/history-notes/src/backend_tests.rs index 65b1973259..31da7b7ede 100644 --- a/codex-rs/ext/history-notes/src/backend_tests.rs +++ b/codex-rs/ext/history-notes/src/backend_tests.rs @@ -14,6 +14,7 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; +use super::ENCRYPTED_TOOL_ARGUMENTS_HEADER; use super::HistoryNotesBackend; #[tokio::test] @@ -62,6 +63,12 @@ async fn routes_through_codex_backend_and_injects_trusted_session_agent_context( assert_eq!(response, json!({"encrypted_output": "enc_payload"})); let requests = server.received_requests().await.expect("recorded requests"); assert_eq!(requests.len(), 1); + assert!( + requests[0] + .headers + .get(ENCRYPTED_TOOL_ARGUMENTS_HEADER) + .is_none() + ); assert_eq!( serde_json::from_slice::(&requests[0].body).expect("JSON body"), json!({ @@ -73,3 +80,57 @@ async fn routes_through_codex_backend_and_injects_trusted_session_agent_context( }) ); } + +#[tokio::test] +async fn marks_encrypted_history_and_notes_arguments_without_changing_the_json_body() { + let server = MockServer::start().await; + let cases = [ + ( + "history/v2/search_contents", + json!({"query": "encrypted-query"}), + ), + ( + "notes/v2/search_contents", + json!({"query": "encrypted-query"}), + ), + ( + "notes/v2/append_to_file", + json!({"path": "notes.md", "text": "encrypted-text"}), + ), + ( + "notes/v2/write_file", + json!({"path": "notes.md", "text": "encrypted-text"}), + ), + ]; + for (route, _) in &cases { + Mock::given(method("POST")) + .and(path(format!("/backend-api/codex/alpha/{route}"))) + .and(header(ENCRYPTED_TOOL_ARGUMENTS_HEADER, "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + } + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::Headers(AuthHeaders::new(HeaderMap::new()))); + let backend = HistoryNotesBackend::new(create_model_provider( + ModelProviderInfo::create_openai_provider(Some(format!( + "{}/backend-api/codex", + server.uri() + ))), + Some(auth_manager), + )); + + for (route, arguments) in cases { + backend + .call( + &format!("alpha/{route}"), + "session-123", + "/root", + arguments.clone(), + ) + .await + .expect("encrypted argument request should succeed"); + } +} diff --git a/codex-rs/ext/history-notes/src/tools.rs b/codex-rs/ext/history-notes/src/tools.rs index 0d1454115f..2c75581586 100644 --- a/codex-rs/ext/history-notes/src/tools.rs +++ b/codex-rs/ext/history-notes/src/tools.rs @@ -179,7 +179,7 @@ impl HistoryNotesAction { "type": "object", "properties": { "limit": {"type": "integer", "minimum": 1, "description": "Maximum number of matching items to return."}, - "query": {"type": "string", "description": "Case-sensitive literal substring to find in item content."}, + "query": {"type": "string", "encrypted": true, "description": "Case-sensitive literal substring to find in item content."}, "recent_first": {"type": "boolean", "description": "Whether to return the most recently created matches first."}, "tool_namespace": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Callable namespace to include. When set, non-tool messages are excluded."}, "role": {"anyOf": [{"type": "string", "enum": ["user", "assistant", "tool", "system", "developer"]}, {"type": "null"}], "description": "Message role to include. Null or omission includes all roles."}, @@ -211,7 +211,7 @@ impl HistoryNotesAction { "type": "object", "properties": { "max_matches_per_file": {"type": "integer", "minimum": 1, "description": "Maximum number of matching lines returned per file."}, - "query": {"type": "string", "description": "Case-sensitive literal substring to find in note lines."}, + "query": {"type": "string", "encrypted": true, "description": "Case-sensitive literal substring to find in note lines."}, "recent_file_first": {"type": "boolean", "description": "Whether to order matching files by creation time, newest first."}, "max_files": {"type": "integer", "minimum": 1, "description": "Maximum number of matching files returned."}, "path_prefix": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Note path prefix to search."} @@ -221,7 +221,7 @@ impl HistoryNotesAction { Self::NotesAppendToFile => json!({ "type": "object", "properties": { - "text": {"type": "string", "description": "Text appended exactly as provided."}, + "text": {"type": "string", "encrypted": true, "description": "Text appended exactly as provided."}, "path": {"type": "string", "description": "Note file path to append to."} }, "required": ["text", "path"] @@ -229,7 +229,7 @@ impl HistoryNotesAction { Self::NotesWriteFile => json!({ "type": "object", "properties": { - "text": {"type": "string", "description": "Complete replacement text for the file."}, + "text": {"type": "string", "encrypted": true, "description": "Complete replacement text for the file."}, "path": {"type": "string", "description": "Note file path to create or replace."} }, "required": ["text", "path"]