Skip to content

Commit ac2e749

Browse files
committed
agent-loop: encrypt tool_calls.function.{name,arguments} under E2EE
PR #144 follow-up: Pierre flagged that with E2EE active but without X-Encrypt-All-Fields, the model-generated search query was still leaking plaintext via the upstream tool_calls[].function.arguments chunks. Same privacy class as the user's E2EE-decrypted prompt — must not be on the wire in the clear. Fix: in routes/chat.rs's agent-loop branch, force `encrypt_all_fields = true` on the EncryptionContext used to build the loop's chunk_transform. This only affects the agent-loop path — the regular chat path below still honors the client's X-Encrypt-All-Fields choice, so existing E2EE clients are unaffected. Why not require X-Encrypt-All-Fields instead: this path is opt-in via the namespaced tool type; clients that opt into it don't need to remember a separate header to get the full privacy guarantee. Test: extended e2ee_without_encrypt_all_fields_encrypts_tool_result to also assert tool_calls[0].function.arguments (the model-generated query) and tool_calls[0].function.name are ciphertext on the wire and decrypt back to {"query":"rust"} and "web_context_search" respectively. The synthetic nearai_tool_result envelope legitimately keeps `name` as plaintext metadata identifying which server-side tool ran; only the envelope's `output` is sensitive and that remains encrypted.
1 parent 619cfed commit ac2e749

2 files changed

Lines changed: 77 additions & 12 deletions

File tree

src/routes/chat.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,18 @@ pub async fn chat_completions(
7373
));
7474
}
7575

76-
// Build chunk transform if E2EE is active; the loop emits synthetic
77-
// `nearai_tool_result` chunks that the transform encrypts alongside
78-
// the model's deltas.
79-
let chunk_transform = enc_ctx.map(|ctx| {
76+
// Build chunk transform if E2EE is active. The agent loop is the
77+
// privacy-critical path — both our synthetic `nearai_tool_result`
78+
// chunks AND the model's own `tool_calls[].function.{name,arguments}`
79+
// (which contain the search query the model just generated from the
80+
// user's E2EE-decrypted prompt) must travel encrypted. Force
81+
// `encrypt_all_fields: true` on the context used to build this
82+
// transform so clients don't need to remember to send
83+
// `X-Encrypt-All-Fields: true` to get the full privacy guarantee.
84+
// This only affects the agent-loop path; the regular chat path
85+
// below still honors the client's `X-Encrypt-All-Fields` choice.
86+
let chunk_transform = enc_ctx.map(|mut ctx| {
87+
ctx.encrypt_all_fields = true;
8088
encryption::make_chunk_transform(Endpoint::ChatCompletions, ctx, state.signing.clone())
8189
});
8290
return agent_loop::run_chat_completion(

tests/agent_loop.rs

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -940,7 +940,8 @@ async fn e2ee_without_encrypt_all_fields_encrypts_tool_result() {
940940
assert_eq!(response.status(), StatusCode::OK);
941941

942942
let body = body_to_string(response).await;
943-
// Locate the nearai_tool_result chunk.
943+
944+
// ── nearai_tool_result.output must be encrypted ──────────────────
944945
let tool_result_chunk = body
945946
.lines()
946947
.find(|l| l.contains("nearai_tool_result"))
@@ -952,16 +953,72 @@ async fn e2ee_without_encrypt_all_fields_encrypts_tool_result() {
952953
let output = parsed["choices"][0]["delta"]["nearai_tool_result"]["output"]
953954
.as_str()
954955
.expect("output field is a string");
955-
956-
// The plaintext "Example A" / "First snippet" must NOT appear on the
957-
// wire — the output should be encrypted (NaCl box hex blob).
958956
assert!(
959957
!output.contains("Example A") && !output.contains("First snippet"),
960958
"tool result output was sent plaintext under E2EE: {output}"
961959
);
962-
// And the encrypted output should round-trip back to plaintext for the client.
963-
let decrypted =
960+
let decrypted_output =
964961
encryption::decrypt_string(output, &dec_for_response, &client_pair).expect("decrypt");
965-
assert!(decrypted.contains("Example A"));
966-
assert!(decrypted.contains("First snippet"));
962+
assert!(decrypted_output.contains("Example A"));
963+
assert!(decrypted_output.contains("First snippet"));
964+
965+
// ── tool_calls[].function.{name,arguments} must also be encrypted
966+
// even though the client never sent X-Encrypt-All-Fields. The
967+
// arguments field holds the model-generated search query, which is the
968+
// same privacy class as the user's original prompt.
969+
//
970+
// (Note: the synthetic `nearai_tool_result` envelope legitimately
971+
// contains a plaintext `name` field identifying which server-side tool
972+
// ran. That's metadata, not user data — its value is a fixed string
973+
// controlled by the proxy. Sensitive fields on the envelope —
974+
// `output` — are encrypted, checked above.)
975+
976+
// Find a chunk carrying the assembled tool_call function args. The
977+
// upstream emits the args delta on its own chunk; the encrypt path
978+
// replaces the string in-place so the chunk still has the field shape
979+
// but its value is ciphertext.
980+
let chunks: Vec<serde_json::Value> = body
981+
.lines()
982+
.filter_map(|l| l.strip_prefix("data: "))
983+
.filter(|s| *s != "[DONE]")
984+
.filter_map(|s| serde_json::from_str::<serde_json::Value>(s).ok())
985+
.collect();
986+
987+
let args_ciphertext = chunks
988+
.iter()
989+
.find_map(|chunk| {
990+
chunk["choices"][0]["delta"]["tool_calls"][0]["function"]["arguments"]
991+
.as_str()
992+
.map(|s| s.to_string())
993+
.filter(|s| !s.is_empty())
994+
})
995+
.expect("expected a tool_calls function.arguments chunk");
996+
assert!(
997+
!args_ciphertext.contains("query") && !args_ciphertext.contains("rust"),
998+
"function.arguments was sent plaintext under E2EE: {args_ciphertext}"
999+
);
1000+
let decrypted_args =
1001+
encryption::decrypt_string(&args_ciphertext, &dec_for_response, &client_pair)
1002+
.expect("decrypt arguments");
1003+
assert!(
1004+
decrypted_args.contains(r#""query":"rust""#),
1005+
"decrypted args did not match expected query: {decrypted_args}"
1006+
);
1007+
1008+
// function.name on the model-generated tool_call should also be
1009+
// ciphertext (round-trips to "web_context_search").
1010+
let name_ciphertext = chunks
1011+
.iter()
1012+
.find_map(|chunk| {
1013+
chunk["choices"][0]["delta"]["tool_calls"][0]["function"]["name"]
1014+
.as_str()
1015+
.map(|s| s.to_string())
1016+
.filter(|s| !s.is_empty())
1017+
})
1018+
.expect("expected a tool_calls function.name chunk");
1019+
assert_ne!(name_ciphertext, "web_context_search");
1020+
let decrypted_name =
1021+
encryption::decrypt_string(&name_ciphertext, &dec_for_response, &client_pair)
1022+
.expect("decrypt name");
1023+
assert_eq!(decrypted_name, "web_context_search");
9671024
}

0 commit comments

Comments
 (0)