use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::{Map, Value};
fn is_redaction_enabled() -> bool {
crate::config::Config::load()
.map(|c| c.agent.redact_sensitive_data)
.unwrap_or(true)
}
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",
"email",
];
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))
}
pub(crate) fn redact_command(cmd: &str) -> String {
if !is_redaction_enabled() {
return cmd.to_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 {
if !is_redaction_enabled() {
return value.clone();
}
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 KEYVAL_SECRET_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|secret|password|passwd|token)\s*=\s*"?([^\s"'`,)\]}>|;&\[]+)"#,
)
.unwrap()
});
static LABELED_CRED_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?i)\b(password|passwd|pass|token|secret|api[_ ]?key|credential|auth|email)\s*:\s*"?([^\s"',;`\[]+)"?"#
).unwrap()
});
static URL_PASSWORD_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(://[^:/\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 {
if !is_redaction_enabled() {
return text.to_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]");
}
let raw_next = after.saturating_add("[REDACTED]".len());
search_from = if raw_next >= result.len() {
result.len()
} else {
result.ceil_char_boundary(raw_next)
};
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 = KEYVAL_SECRET_RE
.replace_all(&result, |caps: ®ex::Captures| {
let key = caps.get(1).unwrap().as_str();
format!("{key}=[REDACTED]")
})
.into_owned();
result = LABELED_CRED_RE
.replace_all(&result, |caps: ®ex::Captures| {
let key = caps.get(1).unwrap().as_str();
if let Some(val) = caps.get(2) {
let v = val.as_str();
if v.len() < 8 {
return caps.get(0).unwrap().as_str().to_string();
}
}
format!("{key}: [REDACTED]")
})
.into_owned();
result = URL_PASSWORD_RE
.replace_all(&result, |caps: ®ex::Captures| {
format!(
"{}[REDACTED]{}",
caps.get(1).unwrap().as_str(),
caps.get(3).unwrap().as_str()
)
})
.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 result.contains("<think>") {
result = strip_think_tags(&result);
}
if result.contains("<reasoning>") {
result = strip_reasoning_tags(&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_think_tags(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut remaining = text;
while let Some(start) = remaining.find("<think>") {
result.push_str(&remaining[..start]);
if let Some(end) = remaining[start..].find("</think>") {
remaining = &remaining[start + end + 8..]; } else {
remaining = "";
}
}
result.push_str(remaining);
result.trim().to_string()
}
pub(crate) fn strip_reasoning_tags(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut remaining = text;
while let Some(start) = remaining.find("<reasoning>") {
result.push_str(&remaining[..start]);
if let Some(end) = remaining[start..].find("</reasoning>") {
remaining = &remaining[start + end + 13..]; } else {
remaining = "";
}
}
result.push_str(remaining);
result.trim().to_string()
}
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(),
}
}