mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
|||||||
Reference in new issue
Block a user