use super::*;
pub(super) fn sanitize_tool_label(call: &ToolCall, label: &str) -> String {
if is_tool(&call.name, ToolCapability::Read) {
sanitize_read_target(label)
} else {
sanitize_display_text(label)
}
}
pub(super) fn sanitize_read_target(text: &str) -> String {
sanitize_display_text(text)
.chars()
.filter(|character| !character.is_control())
.collect()
}
pub(super) fn sanitize_tool_params(call: &ToolCall) -> Value {
if ToolCapability::from_dispatch_name(&call.name) == Some(ToolCapability::Read) {
sanitize_read_params(&call.arguments)
} else {
sanitize_detail_json(&call.arguments)
}
}
fn sanitize_read_params(value: &Value) -> Value {
match value {
Value::Object(object) => {
let mut sanitized = serde_json::Map::new();
for (key, value) in object {
let key = sanitize_read_target(key);
let value = if is_credential_like_key(&key) {
Value::String("<redacted>".to_string())
} else {
sanitize_read_params(value)
};
let key = unique_sanitized_key(&sanitized, key);
sanitized.insert(key, value);
}
Value::Object(sanitized)
}
Value::Array(values) => Value::Array(values.iter().map(sanitize_read_params).collect()),
Value::String(text) => Value::String(sanitize_read_target(text)),
other => other.clone(),
}
}
pub(super) fn sanitize_read_metadata(value: &Value) -> Value {
sanitize_read_metadata_value(value, None)
}
fn sanitize_read_metadata_value(value: &Value, parent_key: Option<&str>) -> Value {
match value {
Value::Object(object) => {
let mut sanitized = serde_json::Map::new();
for (key, value) in object {
let key = sanitize_display_text(key);
let value = if is_credential_like_key(&key) {
Value::String("<redacted>".to_string())
} else {
sanitize_read_metadata_value(value, Some(&key))
};
let key = unique_sanitized_key(&sanitized, key);
sanitized.insert(key, value);
}
Value::Object(sanitized)
}
Value::Array(values) => Value::Array(
values
.iter()
.map(|value| sanitize_read_metadata_value(value, parent_key))
.collect(),
),
Value::String(text) => match parent_key {
Some("path" | "paths" | "source" | "requested_path" | "url") => {
Value::String(sanitize_read_target(text))
}
Some("error") => Value::String(sanitize_read_target(text)),
_ => Value::String(sanitize_detail_output(text)),
},
other => other.clone(),
}
}
pub(super) fn sanitize_detail_json(value: &Value) -> Value {
match value {
Value::Object(object) => {
let mut sanitized = serde_json::Map::new();
for (key, value) in object {
let key = sanitize_display_text(key);
let value = if is_credential_like_key(&key) {
Value::String("<redacted>".to_string())
} else {
sanitize_detail_json(value)
};
let key = unique_sanitized_key(&sanitized, key);
sanitized.insert(key, value);
}
Value::Object(sanitized)
}
Value::Array(values) => Value::Array(values.iter().map(sanitize_detail_json).collect()),
Value::String(text) => Value::String(sanitize_detail_output(text)),
other => other.clone(),
}
}
fn unique_sanitized_key(object: &serde_json::Map<String, Value>, key: String) -> String {
if !object.contains_key(&key) {
return key;
}
let mut index = 2usize;
loop {
let candidate = format!("{key} [{index}]");
if !object.contains_key(&candidate) {
return candidate;
}
index += 1;
}
}
pub(super) fn sanitize_detail_output(text: &str) -> String {
sanitize_detail_text(text, 0)
}
fn sanitize_detail_text(text: &str, depth: usize) -> String {
let mut replacements = Vec::<(usize, usize, String)>::new();
collect_detail_replacements(text, depth, &mut replacements);
if replacements.is_empty() {
return sanitize_display_text(text);
}
let mut output = String::with_capacity(text.len());
let mut copied_through = 0;
for (start, end, replacement) in replacements {
if start < copied_through {
continue;
}
output.push_str(&sanitize_display_text(&text[copied_through..start]));
output.push_str(&replacement);
copied_through = end;
}
output.push_str(&sanitize_display_text(&text[copied_through..]));
output
}
fn collect_detail_replacements(
text: &str,
depth: usize,
replacements: &mut Vec<(usize, usize, String)>,
) {
const MAX_JSON_STRING_DEPTH: usize = 32;
if depth >= MAX_JSON_STRING_DEPTH {
replacements.push((0, text.len(), "<redacted>".to_string()));
return;
}
let mut scan_from = 0;
while scan_from < text.len() {
let Some(relative_start) = text[scan_from..].find('"') else {
break;
};
let start = scan_from + relative_start;
let Some((token_end, decoded)) = parse_json_string_at(text, start) else {
scan_from = start + 1;
continue;
};
let after_token = skip_json_whitespace(text, token_end);
if text.as_bytes().get(after_token) == Some(&b':') {
let key = sanitize_display_text(&decoded);
if is_credential_like_key(&key) {
let value_start = skip_diff_prefixed_value_start(text, start, after_token + 1);
let value_end = json_value_end(text, value_start).unwrap_or(text.len());
let mut replacement = sanitize_display_controls(&text[start..value_start]);
replacement.push_str("\"<redacted>\"");
replacements.push((start, value_end, replacement));
scan_from = value_end.max(value_start + 1).min(text.len());
} else {
scan_from = token_end;
}
continue;
}
let mut nested = Vec::new();
collect_detail_replacements(&decoded, depth + 1, &mut nested);
if !nested.is_empty()
&& let Some(boundaries) = json_string_boundary_map(&text[start..token_end], &decoded)
{
for (nested_start, nested_end, replacement) in nested {
let Ok(start_index) =
boundaries.binary_search_by_key(&nested_start, |(decoded, _)| *decoded)
else {
continue;
};
let Ok(end_index) =
boundaries.binary_search_by_key(&nested_end, |(decoded, _)| *decoded)
else {
continue;
};
replacements.push((
start + boundaries[start_index].1,
start + boundaries[end_index].1,
encode_json_string_fragment(&replacement),
));
}
}
scan_from = token_end;
}
}
pub(super) fn sanitize_read_block_output(result: &ToolResult) -> String {
if !result.success {
return sanitize_read_target(&result.content);
}
let results = result
.metadata
.get("results")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let mut next_result = 0usize;
let mut failed_section = false;
result
.content
.lines()
.map(|line| {
if let Some(item) = results.get(next_result)
&& let Some(requested_path) = item.get("requested_path").and_then(Value::as_str)
&& line == format!("--- FILE: {requested_path} ---")
{
failed_section = item.get("success").and_then(Value::as_bool) == Some(false);
next_result += 1;
return sanitize_read_target(line);
}
if failed_section {
sanitize_read_target(line)
} else {
sanitize_display_text(line)
}
})
.collect::<Vec<_>>()
.join("\n")
}