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);
}
fn mask_value(value: &mut Value, config: &KeywordMaskingConfig, under_structural_key: bool) {
match value {
Value::String(text) if !under_structural_key => {
let masked = config.apply_masking(text);
if masked != *text {
*text = masked;
}
}
Value::Array(items) => {
for item in items {
mask_value(item, config, false);
}
}
Value::Object(map) => {
for (key, val) in map.iter_mut() {
let structural = STRUCTURAL_KEYS.contains(&key.as_str());
mask_value(val, config, structural);
}
}
_ => {}
}
}
#[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 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"); }
}