use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::{Map, Value};
const SENSITIVE_KEYS: &[&str] = &[
"authorization",
"api_key",
"apikey",
"api-key",
"x-api-key",
"x-auth-token",
"x-access-token",
"token",
"secret",
"password",
"passwd",
"pass",
"credential",
"credentials",
"access_token",
"refresh_token",
"client_secret",
"private_key",
"auth",
"bearer",
];
const COMMAND_SENSITIVE_PATTERNS: &[&str] = &[
"bearer ",
"authorization: ",
"x-api-key: ",
"x-auth-token: ",
"api_key=",
"apikey=",
"api-key=",
"token=",
"secret=",
"password=",
"passwd=",
"access_token=",
"_pass=",
"_password=",
"_passwd=",
"_secret=",
"_token=",
"_key=",
"_apikey=",
"_api_key=",
"_credential=",
"_credentials=",
"_auth=",
];
fn is_sensitive_key(key: &str) -> bool {
let lower = key.to_lowercase();
SENSITIVE_KEYS
.iter()
.any(|&pat| lower == pat || lower.contains(pat))
}
fn redact_command(cmd: &str) -> String {
let mut result = cmd.to_string();
if let Some(at_pos) = result.find("://") {
let rest = &result[at_pos + 3..];
if let Some(at_sign) = rest.find('@')
&& let Some(colon) = rest[..at_sign].find(':')
{
let pass_start = at_pos + 3 + colon + 1;
let pass_end = at_pos + 3 + at_sign;
if pass_start < pass_end && pass_end <= result.len() {
result.replace_range(pass_start..pass_end, "[REDACTED]");
}
}
}
for pattern in COMMAND_SENSITIVE_PATTERNS {
let mut search_start = 0;
while let Some(abs_pos) = find_case_insensitive(&result[search_start..], pattern) {
let true_pos = search_start + abs_pos;
let after = true_pos + pattern.len();
if after > result.len() {
break;
}
let secret_end = result[after..]
.find(['"', '\'', ' ', '&', '\n'])
.map(|p| after + p)
.unwrap_or(result.len());
if secret_end > after {
result.replace_range(after..secret_end, "[REDACTED]");
}
search_start = after.saturating_add("[REDACTED]".len());
if search_start >= result.len() {
break;
}
}
}
result = ENV_SECRET_RE
.replace_all(&result, |caps: ®ex::Captures| {
let var_name = caps.get(1).unwrap().as_str();
format!("{var_name}=[REDACTED]")
})
.into_owned();
result = PIPED_SECRET_RE
.replace_all(&result, |caps: ®ex::Captures| {
let full_match = caps.get(0).unwrap().as_str();
let secret = caps.get(1).unwrap().as_str();
full_match.replace(secret, "[REDACTED]")
})
.into_owned();
result = IPV4_RE
.replace_all(&result, |caps: ®ex::Captures| {
let ip = caps.get(1).unwrap().as_str();
if ip == "127.0.0.1" || ip == "0.0.0.0" {
ip.to_string()
} else {
"[IP_REDACTED]".to_string()
}
})
.into_owned();
result
}
fn find_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
debug_assert!(needle.is_ascii());
if needle.is_empty() {
return Some(0);
}
let first = needle.as_bytes()[0];
let rest = &needle[1..];
for (pos, chunk) in haystack.as_bytes().windows(needle.len()).enumerate() {
if chunk[0].eq_ignore_ascii_case(&first)
&& chunk[1..]
.iter()
.enumerate()
.all(|(i, &b)| b.eq_ignore_ascii_case(&rest.as_bytes()[i]))
{
return Some(pos);
}
}
None
}
fn shrink_home_paths(value: &Value) -> Value {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.or_else(|_| {
let drive = std::env::var("HOMEDRIVE").unwrap_or_default();
let path = std::env::var("HOMEPATH").unwrap_or_default();
let combined = format!("{}{}", drive, path);
if combined.is_empty() {
Err(std::env::VarError::NotPresent)
} else {
Ok(combined)
}
});
let home = match home {
Ok(h) if !h.is_empty() => h,
_ => return value.clone(),
};
shrink_home_paths_inner(value, &home)
}
fn shrink_home_paths_inner(value: &Value, home: &str) -> Value {
match value {
Value::String(s) => {
let shortened = s.replace(home, "~");
Value::String(shortened)
}
Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (k, v) in map {
out.insert(k.clone(), shrink_home_paths_inner(v, home));
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(
arr.iter()
.map(|v| shrink_home_paths_inner(v, home))
.collect(),
),
other => other.clone(),
}
}
pub fn redact_tool_input(value: &Value) -> Value {
let shortened = shrink_home_paths(value);
redact_value(&shortened, None)
}
fn redact_value(value: &Value, parent_key: Option<&str>) -> Value {
match value {
Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (k, v) in map {
let redacted = if is_sensitive_key(k) {
Value::String("[REDACTED]".to_string())
} else if k == "command" {
match v.as_str() {
Some(cmd) => Value::String(redact_command(cmd)),
None => redact_value(v, Some(k)),
}
} else if k == "headers" {
redact_headers_object(v)
} else if k == "query" || k == "params" {
redact_value(v, Some(k))
} else if k == "url" {
match v.as_str() {
Some(url) => Value::String(redact_command(url)),
None => redact_value(v, Some(k)),
}
} else {
redact_value(v, Some(k))
};
out.insert(k.clone(), redacted);
}
Value::Object(out)
}
Value::Array(arr) => {
if parent_key.map(is_sensitive_key).unwrap_or(false) {
Value::String("[REDACTED]".to_string())
} else {
Value::Array(arr.iter().map(|v| redact_value(v, None)).collect())
}
}
Value::String(s) => {
if parent_key.map(is_sensitive_key).unwrap_or(false) {
Value::String("[REDACTED]".to_string())
} else {
Value::String(s.clone())
}
}
other => other.clone(),
}
}
const KEY_PREFIXES: &[(&str, usize)] = &[
("sk-proj-", 20), ("sk-ant-api03-", 20), ("sk-ant-", 20), ("sk-or-v1-", 20), ("sk-cp-", 20), ("sk-", 20), ("gsk_", 15), ("nvapi-", 15), ("AIzaSy", 20), ("ya29.", 20), ("pplx-", 20), ("hf_", 15), ("r8_", 20), ("AKIA", 12), ("ASIA", 12), ("ABIA", 12), ("ACCA", 12), ("DefaultEndpointsProtocol=", 10), ("sk_live_", 15), ("sk_test_", 15), ("pk_live_", 15), ("pk_test_", 15), ("rk_live_", 15), ("rk_test_", 15), ("sq0atp-", 15), ("sq0csp-", 15), ("ghp_", 15), ("gho_", 15), ("ghu_", 15), ("ghs_", 15), ("github_pat_", 15), ("glpat-", 15), ("gloas-", 15), ("npm_", 15), ("pypi-AgEIcHlwaS", 10), ("xoxb-", 15), ("xoxp-", 15), ("xapp-", 15), ("xoxs-", 15), ("SG.", 20), ("xkeysib-", 15), ("shpat_", 15), ("shpca_", 15), ("shppa_", 15), ("shpss_", 15), ("ntn_", 15), ("lin_api_", 15), ("aio_", 15), ("phc_", 15), ("sntrys_", 15), ("dop_v1_", 15), ("tskey-", 15), ("tvly-", 15), ("hvs.", 15), ("vault:v1:", 10), ("AGE-SECRET-KEY-", 10), ("EAA", 30), ("ATTA", 30), ("eyJ", 30), ("whsec_", 15), ];
static HEX_TOKEN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b[0-9a-fA-F]{32,}\b").unwrap());
static MIXED_ALNUM_TOKEN_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b[a-zA-Z0-9]{28,}\b").unwrap());
static ENV_SECRET_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"\b([A-Z][A-Z0-9_]*(?i:_PASS|_PASSWORD|_PASSWD|_SECRET|_TOKEN|_KEY|_APIKEY|_API_KEY|_CREDENTIAL|_CREDENTIALS|_AUTH))\s*=\s*(?:")?([^\s"']+)"#).unwrap()
});
static PIPED_SECRET_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"(?i)echo\s+["']([a-zA-Z0-9_\-+/=]{20,})["']\s*\|"#).unwrap());
static IPV4_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b").unwrap());
static QWEN_TOOL_MARKER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"<\|tool[\u{2581}_][^|]*\|>").unwrap());
pub fn redact_secrets(text: &str) -> String {
let mut result = text.to_string();
for &(prefix, min_suffix_len) in KEY_PREFIXES {
let mut search_from = 0;
while let Some(abs_pos) = find_case_insensitive(&result[search_from..], prefix) {
let true_pos = search_from + abs_pos;
let after = true_pos + prefix.len();
if after > result.len() {
break;
}
let end = result[after..]
.find(|c: char| {
c.is_whitespace()
|| matches!(
c,
'"' | '\'' | ',' | '`' | ')' | ']' | '}' | '>' | '|' | ';'
)
})
.map(|p| after + p)
.unwrap_or(result.len());
let suffix_len = end - after;
if suffix_len >= min_suffix_len {
result.replace_range(after..end, "[REDACTED]");
}
search_from = after.saturating_add("[REDACTED]".len());
if search_from >= result.len() {
break;
}
}
}
let is_url_path_segment =
|input: &str, match_start: usize| -> bool { input[..match_start].ends_with('/') };
result = HEX_TOKEN_RE
.replace_all(&result, |caps: ®ex::Captures| {
let m = caps.get(0).unwrap();
let token = m.as_str();
if is_url_path_segment(&result, m.start()) {
token.to_string()
} else {
"[REDACTED_TOKEN]".to_string()
}
})
.into_owned();
result = MIXED_ALNUM_TOKEN_RE
.replace_all(&result, |caps: ®ex::Captures| {
let m = caps.get(0).unwrap();
let token = m.as_str();
let has_digit = token.chars().any(|c| c.is_ascii_digit());
let has_alpha = token.chars().any(|c| c.is_ascii_alphabetic());
if has_digit && has_alpha && !is_url_path_segment(&result, m.start()) {
"[REDACTED_TOKEN]".to_string()
} else {
token.to_string()
}
})
.into_owned();
for pattern in &["bearer ", "authorization: bearer "] {
let mut search_start = 0;
while let Some(abs_pos) = find_case_insensitive(&result[search_start..], pattern) {
let true_pos = search_start + abs_pos;
let after = true_pos + pattern.len();
if after > result.len() {
break;
}
let end = result[after..]
.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '`' | ')'))
.map(|p| after + p)
.unwrap_or(result.len());
if end > after {
result.replace_range(after..end, "[REDACTED]");
}
search_start = after.saturating_add("[REDACTED]".len());
if search_start >= result.len() {
break;
}
}
}
result = ENV_SECRET_RE
.replace_all(&result, |caps: ®ex::Captures| {
let var_name = caps.get(1).unwrap().as_str();
format!("{var_name}=[REDACTED]")
})
.into_owned();
result = PIPED_SECRET_RE
.replace_all(&result, |caps: ®ex::Captures| {
let full_match = caps.get(0).unwrap().as_str();
let secret = caps.get(1).unwrap().as_str();
full_match.replace(secret, "[REDACTED]")
})
.into_owned();
result = IPV4_RE
.replace_all(&result, |caps: ®ex::Captures| {
let ip = caps.get(1).unwrap().as_str();
if ip == "127.0.0.1" || ip == "0.0.0.0" {
ip.to_string()
} else {
"[IP_REDACTED]".to_string()
}
})
.into_owned();
result
}
pub fn strip_llm_artifacts(text: &str) -> String {
use crate::brain::agent::service::AgentService;
let mut result = text.to_string();
if result.contains("<|tool") {
result = QWEN_TOOL_MARKER_RE.replace_all(&result, "").into_owned();
}
if result.contains("<!--") {
result = AgentService::strip_html_comments(&result);
}
if result.contains("CODE_EDIT_BLOCK") {
result = strip_code_edit_block_fences(&result);
}
if AgentService::has_xml_tool_block(&result) {
let parsed = AgentService::parse_xml_tool_calls(&result);
if !parsed.is_empty() {
result = AgentService::strip_xml_tool_calls(&result);
}
}
if result.contains("</tool_result>")
|| result.contains("</tool_call>")
|| result.contains("</tool_use>")
|| result.contains("</invoke>")
|| result.contains("</function_calls>")
|| result.contains("</qwen:tool_call>")
|| result.contains("</minimax:tool_call>")
{
result = AgentService::strip_xml_tool_calls(&result);
}
result
}
pub(crate) fn strip_code_edit_block_fences(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len());
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if is_code_edit_block_open(line) {
let path = extract_code_edit_block_path(line).unwrap_or("(unknown)");
let mut j = i + 1;
while j < lines.len() && !lines[j].trim_start().starts_with("```") {
j += 1;
}
out.push(format!(
"[Agent attempted to edit `{path}` using an unsupported \
inline-edit format. The change was NOT applied — agent \
should retry via the `edit_file` tool.]"
));
i = j.saturating_add(1);
continue;
}
out.push(line.to_string());
i += 1;
}
out.join("\n")
}
fn is_code_edit_block_open(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("```") && trimmed.contains("CODE_EDIT_BLOCK")
}
fn extract_code_edit_block_path(line: &str) -> Option<&str> {
let after = line.split("CODE_EDIT_BLOCK").nth(1)?;
let path = after.trim_start_matches('|').trim();
if path.is_empty() { None } else { Some(path) }
}
fn redact_headers_object(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (k, v) in map {
let redacted = if is_sensitive_key(k) {
Value::String("[REDACTED]".to_string())
} else {
v.clone()
};
out.insert(k.clone(), redacted);
}
Value::Object(out)
}
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn redacts_authorization_header() {
let input = json!({
"method": "POST",
"url": "https://api.trello.com/1/cards",
"headers": {
"Authorization": "Bearer sk-trello-abc123",
"Content-Type": "application/json"
}
});
let out = redact_tool_input(&input);
assert_eq!(out["headers"]["Authorization"], "[REDACTED]");
assert_eq!(out["headers"]["Content-Type"], "application/json");
}
#[test]
fn redacts_api_key_field() {
let input = json!({"api_key": "secret123", "query": "something"});
let out = redact_tool_input(&input);
assert_eq!(out["api_key"], "[REDACTED]");
assert_eq!(out["query"], "something");
}
#[test]
fn redacts_bash_bearer_token() {
let input = json!({
"command": "curl -H \"Authorization: Bearer sk-abc123\" https://api.example.com"
});
let out = redact_tool_input(&input);
let cmd = out["command"].as_str().unwrap();
assert!(cmd.contains("[REDACTED]"), "expected REDACTED in: {cmd}");
assert!(!cmd.contains("sk-abc123"), "secret still present: {cmd}");
}
#[test]
fn redacts_url_password() {
let input = json!({
"url": "https://user:mysecretpass@api.example.com/v1"
});
let out = redact_tool_input(&input);
let url = out["url"].as_str().unwrap();
assert!(url.contains("[REDACTED]"), "expected REDACTED in: {url}");
assert!(
!url.contains("mysecretpass"),
"password still present: {url}"
);
}
#[test]
fn preserves_non_sensitive_fields() {
let input = json!({
"method": "GET",
"url": "https://api.example.com/data",
"timeout_secs": 30
});
let out = redact_tool_input(&input);
assert_eq!(out["method"], "GET");
assert_eq!(out["timeout_secs"], 30);
}
#[test]
fn redact_secrets_openai_key() {
let text =
"The API key is sk-proj-mrRb3y9swLqHv8ZzB9lPH0_V7RPruzdbnXJf34DxU2RCdQnhCYjS99Tj ok?";
let out = redact_secrets(text);
assert!(out.contains("sk-proj-[REDACTED]"), "got: {out}");
assert!(!out.contains("mrRb3y"), "secret leaked: {out}");
assert!(out.contains("ok?"), "trailing text lost: {out}");
}
#[test]
fn redact_secrets_anthropic_key() {
let text = "Use sk-ant-oat01-H9Uogg04aohFVZn5qymS8R for auth";
let out = redact_secrets(text);
assert!(out.contains("sk-ant-[REDACTED]"), "got: {out}");
assert!(!out.contains("H9Uogg"), "secret leaked: {out}");
}
#[test]
fn redact_secrets_slack_token() {
let token = String::from("xo") + "xb-" + "fake_test_token_not_real";
let text = format!("slack token: {token}");
let out = redact_secrets(&text);
let expected = String::from("xo") + "xb-[REDACTED]";
assert!(out.contains(&expected), "got: {out}");
}
#[test]
fn redact_secrets_google_key() {
let text = "key=AIzaSyFAKE_TEST_KEY_NOT_REAL_000000 for gemini";
let out = redact_secrets(text);
assert!(out.contains("AIzaSy[REDACTED]"), "got: {out}");
}
#[test]
fn redact_secrets_hex_token() {
let text = "auth_token=aa83802d35bb2c4471e7e96f4eaeafa6c96fe42f set";
let out = redact_secrets(text);
assert!(out.contains("[REDACTED_TOKEN]"), "got: {out}");
assert!(!out.contains("aa83802d"), "secret leaked: {out}");
}
#[test]
fn redact_secrets_preserves_normal_text() {
let text = "The model is claude-3-opus and the temperature is 0.7";
let out = redact_secrets(text);
assert_eq!(out, text);
}
#[test]
fn redact_secrets_multiple_keys() {
let text = "OpenAI: sk-proj-AAAAAAAAAAAAAAAAAAAAAA, Groq: gsk_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let out = redact_secrets(text);
assert!(out.contains("sk-proj-[REDACTED]"), "got: {out}");
assert!(out.contains("gsk_[REDACTED]"), "got: {out}");
}
#[test]
fn redact_secrets_stripe_live_key() {
let text = "stripe key: sk_live_FAKE00TEST00KEY00EXAMPLE00VAL";
let out = redact_secrets(text);
assert!(out.contains("sk_live_[REDACTED]"), "got: {out}");
assert!(!out.contains("FAKE00TEST"), "secret leaked: {out}");
}
#[test]
fn redact_secrets_aws_access_key() {
let text = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE";
let out = redact_secrets(text);
assert!(out.contains("AKIA[REDACTED]"), "got: {out}");
assert!(!out.contains("IOSFODNN"), "secret leaked: {out}");
}
#[test]
fn redact_secrets_sendgrid_key() {
let text = "SENDGRID_API_KEY=SG.abc123def456ghi789jkl012mno345pqr678stu901vwx234yz";
let out = redact_secrets(text);
assert!(out.contains("SENDGRID_API_KEY=[REDACTED]"), "got: {out}");
}
#[test]
fn redact_secrets_jwt_token() {
let text = "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N";
let out = redact_secrets(text);
assert!(out.contains("eyJ[REDACTED]"), "got: {out}");
}
#[test]
fn redact_secrets_mixed_alnum_opaque_token() {
let text = "key: 38947394723jkhkrjkhdfiuo83489732 done";
let out = redact_secrets(text);
assert!(
out.contains("[REDACTED_TOKEN]"),
"opaque mixed-alnum token not caught: {out}"
);
assert!(!out.contains("38947394723"), "secret leaked: {out}");
}
#[test]
fn redact_secrets_hex_32_chars() {
let text = "api-key: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 end";
let out = redact_secrets(text);
assert!(
out.contains("[REDACTED_TOKEN]"),
"32-char hex not caught: {out}"
);
}
#[test]
fn redact_secrets_preserves_short_alnum() {
let text = "model claude3opus version 12345 session abc123";
let out = redact_secrets(text);
assert_eq!(out, text, "short strings should be preserved");
}
#[test]
fn redact_secrets_preserves_pure_alpha_long() {
let text = "the acknowledgementofresponsibility was important";
let out = redact_secrets(text);
assert_eq!(out, text, "pure-alpha long string should be preserved");
}
#[test]
fn redact_secrets_shopify_token() {
let text = "token: shpat_abc123def456ghi789jkl012mno";
let out = redact_secrets(text);
assert!(out.contains("shpat_[REDACTED]"), "got: {out}");
}
#[test]
fn redact_secrets_digital_ocean_token() {
let text = "DO_TOKEN=dop_v1_abc123def456ghi789jkl012mno345";
let out = redact_secrets(text);
assert!(out.contains("DO_TOKEN=[REDACTED]"), "got: {out}");
}
#[test]
fn redact_command_unicode_expansion_no_panic() {
let input = "İİİİİİİİİİauthorization: bearer sk-secret-123";
let out = redact_command(input);
assert!(out.contains("[REDACTED]"), "secret not redacted: {out}");
assert!(!out.contains("sk-secret-123"), "secret leaked: {out}");
}
#[test]
fn redact_command_unicode_expansion_api_key() {
let input = "İİİİİİİİİİapi_key=super-secret-key";
let out = redact_command(input);
assert!(out.contains("[REDACTED]"), "secret not redacted: {out}");
assert!(!out.contains("super-secret-key"), "secret leaked: {out}");
}
#[test]
fn redact_secrets_unicode_expansion_no_panic() {
let input = "İİİİİİİİİİ sk-proj-mrRb3y9swLqHv8ZzB9lPH0_V7RPruzdbnXJf34DxU2RCdQnhCYjS99Tj";
let out = redact_secrets(input);
assert!(out.contains("[REDACTED]"), "secret not redacted: {out}");
assert!(!out.contains("mrRb3y"), "secret leaked: {out}");
}
#[test]
fn redact_command_unicode_expansion_bearer() {
let input = "İİİİİİİİİİ bearer eyJhbGc...";
let out = redact_command(input);
assert!(out.contains("[REDACTED]"), "token not redacted: {out}");
assert!(!out.contains("eyJhbGc"), "token leaked: {out}");
}
#[test]
fn redact_secrets_unicode_expansion_bearer() {
let input = "İİİİİİİİİİbearer eyJhbGciOiJIUzI1NiJ9.test";
let out = redact_secrets(input);
assert!(out.contains("[REDACTED]"), "token not redacted: {out}");
assert!(!out.contains("eyJhbGc"), "token leaked: {out}");
}
#[test]
fn redact_command_unicode_normal_text() {
let input = "Normal text with İstanbul and Größe and Ñoño";
let out = redact_command(input);
assert_eq!(out, input, "normal text should not change");
}
#[test]
fn redact_secrets_unicode_normal_text() {
let input = "Hello world, İstanbul, München, Ñoño";
let out = redact_secrets(input);
assert_eq!(out, input, "normal text should not change");
}
#[cfg(unix)]
#[test]
fn shrinks_home_path_in_string() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/testuser".to_string());
let input = json!({"path": format!("{}/srv/rs/opencrabs", home)});
let out = redact_tool_input(&input);
assert_eq!(out["path"], "~/srv/rs/opencrabs");
}
#[cfg(unix)]
#[test]
fn shrinks_home_path_in_nested_object() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/testuser".to_string());
let input = json!({
"config": {
"dir": format!("{}/.opencrabs", home),
"name": "test"
}
});
let out = redact_tool_input(&input);
assert_eq!(out["config"]["dir"], "~/.opencrabs");
assert_eq!(out["config"]["name"], "test");
}
#[cfg(unix)]
#[test]
fn shrinks_home_path_in_array() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/testuser".to_string());
let input = json!([format!("{}/file1.rs", home), format!("{}/file2.rs", home)]);
let out = redact_tool_input(&input);
assert_eq!(out[0], "~/file1.rs");
assert_eq!(out[1], "~/file2.rs");
}
#[cfg(unix)]
#[test]
fn shrinks_home_path_in_bash_command() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/testuser".to_string());
let input = json!({"command": format!("cat {}/.opencrabs/config.toml", home)});
let out = redact_tool_input(&input);
assert!(
out["command"]
.as_str()
.unwrap()
.contains("~/.opencrabs/config.toml")
);
}
#[test]
fn preserves_non_home_paths() {
let input = json!({"path": "/etc/hosts", "other": "/var/log/syslog"});
let out = redact_tool_input(&input);
assert_eq!(out["path"], "/etc/hosts");
assert_eq!(out["other"], "/var/log/syslog");
}
#[cfg(unix)]
#[test]
fn shrinks_home_path_mid_string() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/testuser".to_string());
let input = json!({"msg": format!("Found at {}/docs/readme.md", home)});
let out = redact_tool_input(&input);
assert_eq!(out["msg"], "Found at ~/docs/readme.md");
}
}