use bamboo_config::KeywordMaskingConfig;
use serde_json::Value;
const STRUCTURAL_KEYS: &[&str] = &[
"id",
"call_id",
"tool_call_id",
"tool_use_id", "item_id",
"response_id",
"previous_response_id",
"type",
"role",
"object",
"finish_reason",
"status",
"index",
"model",
"name",
"url",
"fileUri", "signature",
"encrypted_content",
"data",
"media_type",
"mimeType", ];
pub fn mask_outbound_body(body: &mut Value, config: &KeywordMaskingConfig) {
if config.entries.is_empty() {
return;
}
mask_value(body, config, false, MaskContext::Root);
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MaskContext {
Root,
Other,
AnthropicMessages,
AnthropicMessage,
AnthropicContent,
AnthropicContentBlock,
}
fn mask_value(
value: &mut Value,
config: &KeywordMaskingConfig,
under_structural_key: bool,
context: MaskContext,
) {
match value {
Value::String(text) if !under_structural_key => {
let masked = config.apply_masking(text);
if masked != *text {
*text = masked;
}
}
Value::Array(items) => {
let item_context = match context {
MaskContext::AnthropicMessages => MaskContext::AnthropicMessage,
MaskContext::AnthropicContent => MaskContext::AnthropicContentBlock,
_ => MaskContext::Other,
};
for item in items {
mask_value(item, config, false, item_context);
}
}
Value::Object(map) => {
let signed_anthropic_thinking = context == MaskContext::AnthropicContentBlock
&& map.get("type").and_then(Value::as_str) == Some("thinking")
&& map
.get("signature")
.and_then(Value::as_str)
.is_some_and(|signature| !signature.is_empty());
for (key, val) in map.iter_mut() {
let structural = STRUCTURAL_KEYS.contains(&key.as_str())
|| (signed_anthropic_thinking && key == "thinking");
let child_context = match (context, key.as_str(), &*val) {
(MaskContext::Root, "messages", Value::Array(_)) => {
MaskContext::AnthropicMessages
}
(MaskContext::AnthropicMessage, "content", Value::Array(_)) => {
MaskContext::AnthropicContent
}
_ => MaskContext::Other,
};
mask_value(val, config, structural, child_context);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_config::keyword_masking::{KeywordEntry, MatchType};
use serde_json::json;
fn config(pattern: &str) -> KeywordMaskingConfig {
KeywordMaskingConfig {
entries: vec![KeywordEntry {
pattern: pattern.to_string(),
match_type: MatchType::Exact,
enabled: true,
}],
}
}
#[test]
fn masks_content_and_tool_call_arguments_but_not_structural_fields() {
let mut body = json!({
"model": "gpt-secret-5",
"instructions": "system has a secret",
"input": [
{ "type": "message", "role": "user", "content": "a secret here" },
{
"type": "function_call",
"call_id": "call_secret_1",
"name": "secret_tool",
"arguments": "{\"q\":\"the secret value\"}"
},
{ "type": "function_call_output", "call_id": "call_secret_1", "output": "secret output" }
]
});
mask_outbound_body(&mut body, &config("secret"));
assert_eq!(body["model"], "gpt-secret-5"); assert_eq!(body["input"][1]["call_id"], "call_secret_1");
assert_eq!(body["input"][2]["call_id"], "call_secret_1");
assert_eq!(body["input"][1]["name"], "secret_tool"); assert_eq!(body["input"][0]["role"], "user");
assert_eq!(body["input"][1]["type"], "function_call");
assert_eq!(body["instructions"], "system has a [MASKED]");
assert_eq!(body["input"][0]["content"], "a [MASKED] here");
assert_eq!(
body["input"][1]["arguments"],
"{\"q\":\"the [MASKED] value\"}"
);
assert_eq!(body["input"][2]["output"], "[MASKED] output");
}
#[test]
fn masks_typed_content_array_text_but_not_image_url_or_signature() {
let mut body = json!({
"messages": [{
"role": "assistant",
"content": [
{ "type": "text", "text": "secret text" },
{ "type": "image_url", "image_url": { "url": "https://x/secret.png" } }
],
"tool_calls": [{
"id": "tc_1",
"type": "function",
"function": { "name": "secret_fn", "arguments": "secret args" }
}]
}],
"thinking": { "type": "thinking", "signature": "secret-signature-blob" }
});
mask_outbound_body(&mut body, &config("secret"));
assert_eq!(body["messages"][0]["content"][0]["text"], "[MASKED] text");
assert_eq!(
body["messages"][0]["content"][1]["image_url"]["url"],
"https://x/secret.png"
);
assert_eq!(body["messages"][0]["tool_calls"][0]["id"], "tc_1");
assert_eq!(
body["messages"][0]["tool_calls"][0]["function"]["name"],
"secret_fn"
);
assert_eq!(
body["messages"][0]["tool_calls"][0]["function"]["arguments"],
"[MASKED] args"
);
assert_eq!(body["thinking"]["signature"], "secret-signature-blob");
}
#[test]
fn preserves_signed_thinking_while_masking_other_anthropic_native_values() {
let mut body = json!({
"messages": [{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "secret private reasoning",
"signature": "secret-signature-blob"
},
{
"type": "server_tool_use",
"id": "srv_1",
"name": "tool_search_tool_regex",
"input": { "pattern": "secret weather" }
},
{
"type": "tool_use",
"id": "tool_1",
"name": "get_weather",
"input": {
"value": "secret city",
"thinking": "secret user-controlled thought"
}
}
]
}]
});
mask_outbound_body(&mut body, &config("secret"));
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content[0]["thinking"], "secret private reasoning");
assert_eq!(content[0]["signature"], "secret-signature-blob");
assert_eq!(content[1]["input"]["pattern"], "[MASKED] weather");
assert_eq!(content[2]["input"]["value"], "[MASKED] city");
assert_eq!(
content[2]["input"]["thinking"],
"[MASKED] user-controlled thought"
);
}
#[test]
fn is_idempotent_and_noop_when_disabled() {
let mut body = json!({ "content": "a secret" });
let disabled = KeywordMaskingConfig::default();
mask_outbound_body(&mut body, &disabled);
assert_eq!(body["content"], "a secret", "no entries → no-op");
let cfg = config("secret");
mask_outbound_body(&mut body, &cfg);
assert_eq!(body["content"], "a [MASKED]");
mask_outbound_body(&mut body, &cfg);
assert_eq!(body["content"], "a [MASKED]");
}
#[test]
fn gemini_camelcase_structural_keys_are_exempt_like_snake_case() {
let mut body = json!({
"contents": [{
"role": "user",
"parts": [
{ "text": "a secret prompt" },
{ "inlineData": { "mimeType": "secret/type", "data": "c2VjcmV0" } },
{ "fileData": { "mimeType": "x/secret", "fileUri": "gs://b/secret.png" } }
]
}]
});
mask_outbound_body(&mut body, &config("secret"));
let part = |i: usize| &body["contents"][0]["parts"][i];
assert_eq!(part(0)["text"], "a [MASKED] prompt"); assert_eq!(part(1)["inlineData"]["mimeType"], "secret/type"); assert_eq!(part(1)["inlineData"]["data"], "c2VjcmV0"); assert_eq!(part(2)["fileData"]["mimeType"], "x/secret"); assert_eq!(part(2)["fileData"]["fileUri"], "gs://b/secret.png"); }
#[test]
fn documented_residual_keyword_equal_to_a_json_key_inside_arguments_rewrites_the_key() {
let mut body = json!({
"input": [{
"type": "function_call",
"call_id": "c1",
"name": "search",
"arguments": "{\"q\":\"hello\"}"
}]
});
mask_outbound_body(&mut body, &config("q"));
assert_eq!(
body["input"][0]["arguments"], "{\"[MASKED]\":\"hello\"}",
"documented residual: keyword==JSON key corrupts tool-arg JSON (accepted trade-off)"
);
}
#[test]
fn exempts_anthropic_tool_use_id_but_masks_tool_result_text() {
let mut body = json!({
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_secret123",
"content": "the secret result"
}]
}]
});
mask_outbound_body(&mut body, &config("secret"));
let block = &body["messages"][0]["content"][0];
assert_eq!(block["tool_use_id"], "toolu_secret123"); assert_eq!(block["content"], "the [MASKED] result"); }
}