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
This commit is contained in:
pmccrary-oai authored and copyberry committed 2026-08-27 03:59:54 +00:00
1 parent 5af6979986
commit 57e2edc6e9
4 files changed
+100 -9

No files matched your search

@@ -22,6 +22,7 @@ use tokio::time::timeout;
use wiremock::Mock; use wiremock::Mock;
use wiremock::Request; use wiremock::Request;
use wiremock::ResponseTemplate; use wiremock::ResponseTemplate;
use wiremock::matchers::body_partial_json;
use wiremock::matchers::method; use wiremock::matchers::method;
use wiremock::matchers::path; 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result<()> { async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result<()> {
let encrypted_query = format!("enc_query_{}", "x".repeat(1_001));
let calls = [ let calls = [
("history", "list_windows", json!({})), ("history", "list_windows", json!({})),
("history", "list_items", json!({})), ("history", "list_items", json!({})),
@@ -410,7 +412,7 @@ async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result
( (
"history", "history",
"search_contents", "search_contents",
json!({"query": "PRIVATE_QUERY"}), json!({"query": encrypted_query, "limit": 2, "recent_first": false}),
), ),
( (
"notes", "notes",
@@ -421,17 +423,17 @@ async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result
( (
"notes", "notes",
"search_contents", "search_contents",
json!({"query": "PRIVATE_QUERY"}), json!({"query": encrypted_query, "max_files": 2}),
), ),
( (
"notes", "notes",
"append_to_file", "append_to_file",
json!({"path": "PRIVATE_PATH", "text": "PRIVATE_TEXT"}), json!({"path": "PRIVATE_PATH", "text": "enc_append_text"}),
), ),
( (
"notes", "notes",
"write_file", "write_file",
json!({"path": "PRIVATE_PATH", "text": "PRIVATE_TEXT"}), json!({"path": "PRIVATE_PATH", "text": "enc_write_text"}),
), ),
( (
"functions", "functions",
@@ -450,11 +452,12 @@ async fn history_notes_and_async_message_emit_control_tool_analytics() -> Result
), ),
]; ];
let server = responses::start_mock_server().await; let server = responses::start_mock_server().await;
for (namespace, tool, _) in &calls[..9] { for (namespace, tool, arguments) in &calls[..9] {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path(format!( .and(path(format!(
"/backend-api/codex/alpha/{namespace}/v2/{tool}" "/backend-api/codex/alpha/{namespace}/v2/{tool}"
))) )))
.and(body_partial_json(arguments.clone()))
.respond_with( .respond_with(
ResponseTemplate::new(200).set_body_json(json!({"text": "PRIVATE_RESULT"})), 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?; .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() { for (index, (namespace, tool, _)) in calls.iter().enumerate() {
let event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| { let event = wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
event["event_type"] == "codex_control_tool_call_event" event["event_type"] == "codex_control_tool_call_event"
+14
View File
@@ -5,11 +5,13 @@ use codex_client::HttpTransport;
use codex_client::RequestBody; use codex_client::RequestBody;
use codex_login::default_client::create_client; use codex_login::default_client::create_client;
use codex_model_provider::SharedModelProvider; use codex_model_provider::SharedModelProvider;
use http::HeaderValue;
use http::Method; use http::Method;
use serde_json::Value; use serde_json::Value;
use serde_json::json; use serde_json::json;
const HISTORY_NOTES_BACKEND_TIMEOUT: Duration = Duration::from_secs(35); const HISTORY_NOTES_BACKEND_TIMEOUT: Duration = Duration::from_secs(35);
const ENCRYPTED_TOOL_ARGUMENTS_HEADER: &str = "x-openai-encrypted-tool-arguments";
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct HistoryNotesBackend { pub(crate) struct HistoryNotesBackend {
@@ -50,6 +52,18 @@ impl HistoryNotesBackend {
.map_err(|error| format!("History backend auth could not be resolved: {error}"))?; .map_err(|error| format!("History backend auth could not be resolved: {error}"))?;
let mut request = provider.build_request(Method::POST, path); 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.body = Some(RequestBody::Json(arguments));
request.timeout = Some(HISTORY_NOTES_BACKEND_TIMEOUT); request.timeout = Some(HISTORY_NOTES_BACKEND_TIMEOUT);
let request = auth let request = auth
@@ -14,6 +14,7 @@ use wiremock::matchers::header;
use wiremock::matchers::method; use wiremock::matchers::method;
use wiremock::matchers::path; use wiremock::matchers::path;
use super::ENCRYPTED_TOOL_ARGUMENTS_HEADER;
use super::HistoryNotesBackend; use super::HistoryNotesBackend;
#[tokio::test] #[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"})); assert_eq!(response, json!({"encrypted_output": "enc_payload"}));
let requests = server.received_requests().await.expect("recorded requests"); let requests = server.received_requests().await.expect("recorded requests");
assert_eq!(requests.len(), 1); assert_eq!(requests.len(), 1);
assert!(
requests[0]
.headers
.get(ENCRYPTED_TOOL_ARGUMENTS_HEADER)
.is_none()
);
assert_eq!( assert_eq!(
serde_json::from_slice::<serde_json::Value>(&requests[0].body).expect("JSON body"), serde_json::from_slice::<serde_json::Value>(&requests[0].body).expect("JSON body"),
json!({ 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");
}
}
+4 -4
View File
@@ -179,7 +179,7 @@ impl HistoryNotesAction {
"type": "object", "type": "object",
"properties": { "properties": {
"limit": {"type": "integer", "minimum": 1, "description": "Maximum number of matching items to return."}, "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."}, "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."}, "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."}, "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", "type": "object",
"properties": { "properties": {
"max_matches_per_file": {"type": "integer", "minimum": 1, "description": "Maximum number of matching lines returned per file."}, "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."}, "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."}, "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."} "path_prefix": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Note path prefix to search."}
@@ -221,7 +221,7 @@ impl HistoryNotesAction {
Self::NotesAppendToFile => json!({ Self::NotesAppendToFile => json!({
"type": "object", "type": "object",
"properties": { "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."} "path": {"type": "string", "description": "Note file path to append to."}
}, },
"required": ["text", "path"] "required": ["text", "path"]
@@ -229,7 +229,7 @@ impl HistoryNotesAction {
Self::NotesWriteFile => json!({ Self::NotesWriteFile => json!({
"type": "object", "type": "object",
"properties": { "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."} "path": {"type": "string", "description": "Note file path to create or replace."}
}, },
"required": ["text", "path"] "required": ["text", "path"]