clawgarden-agent 0.22.0

Agent runtime with persona/memory loader, judge, and pi RPC for ClawGarden
Documentation
//! Credential scrubbing — removes sensitive values from tool output
//! before sending to the LLM.
//!
//! Inspired by ZeroClaw's `scrub_credentials` in `agent/loop_.rs`.
//! Prevents API keys, tokens, and passwords from leaking into LLM context
//! via exec tool results.

use once_cell::sync::Lazy;
use regex::Regex;

/// Patterns that indicate a sensitive credential in tool output.
/// Matches key-value pairs like `"api_key": "sk-ant-..."` or `TOKEN=abc123`.
static SENSITIVE_KV_REGEX: Lazy<Regex> = Lazy::new(|| {
    Regex::new(
        r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential|auth[_-]?token|access[_-]?key|private[_-]?key)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#
    ).unwrap()
});

/// Matches `Bearer <long-token>` in Authorization headers.
/// e.g. `Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9`
static BEARER_TOKEN_REGEX: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r#"(?i)Bearer\s+([a-zA-Z0-9_\-\.]{8,})"#).unwrap()
});

/// Scrub credential values from a string.
///
/// Preserves the key name and first 4 characters for context,
/// replaces the rest with `*[REDACTED]`.
///
/// # Examples
/// ```
/// use clawgarden_agent::scrub::scrub_credentials;
/// let input = r#"{"api_key": "sk-ant-api03-xxxxxxxxxxxx"}"#;
/// let scrubbed = scrub_credentials(input);
/// assert!(scrubbed.contains("*[REDACTED]*"));
/// assert!(!scrubbed.contains("sk-ant-api03-xxx"));
/// ```
pub fn scrub_credentials(input: &str) -> String {
    // First pass: Bearer token in Authorization header
    let after_bearer = BEARER_TOKEN_REGEX
        .replace_all(input, |caps: &regex::Captures| {
            let val: &str = caps.get(1).map(|m: regex::Match| m.as_str()).unwrap_or("");
            let prefix = if val.len() > 4 {
                val.char_indices()
                    .nth(4)
                    .map(|(byte_idx, _)| &val[..byte_idx])
                    .unwrap_or(val)
            } else {
                ""
            };
            format!("Bearer {}*[REDACTED]*", prefix)
        });

    // Second pass: key-value pairs (api_key=..., "password": ...)
    SENSITIVE_KV_REGEX
        .replace_all(&after_bearer, |caps: &regex::Captures| {
            let full_match = &caps[0];
            let key = &caps[1];
            // Extract the value from whichever capture group matched
            let val: &str = caps
                .get(2)
                .or(caps.get(3))
                .or(caps.get(4))
                .map(|m: regex::Match| m.as_str())
                .unwrap_or("");

            let prefix = if val.len() > 4 {
                val.char_indices()
                    .nth(4)
                    .map(|(byte_idx, _)| &val[..byte_idx])
                    .unwrap_or(val)
            } else {
                ""
            };

            if full_match.contains(':') {
                if full_match.contains('"') {
                    format!(r#""{}": "{}*[REDACTED]*""#, key, prefix)
                } else {
                    format!("{}: {}*[REDACTED]*", key, prefix)
                }
            } else if full_match.contains('=') {
                if full_match.contains('"') {
                    format!(r#"{}="{}*[REDACTED]*""#, key, prefix)
                } else {
                    format!("{}={}*[REDACTED]*", key, prefix)
                }
            } else {
                format!("{}: {}*[REDACTED]*", key, prefix)
            }
        })
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_scrub_json_api_key() {
        let input = r#"{"api_key": "sk-ant-api03-xxxxxxxxxxxxxx"}"#;
        let result = scrub_credentials(input);
        assert!(result.contains("*[REDACTED]*"));
        assert!(!result.contains("sk-ant-api03"));
        assert!(result.contains("api_key"));
    }

    #[test]
    fn test_scrub_env_token() {
        let input = "TOKEN=ghp_abcdef1234567890";
        let result = scrub_credentials(input);
        assert!(result.contains("*[REDACTED]*"));
        assert!(!result.contains("ghp_abcdef"));
        assert!(result.contains("TOKEN"));
    }

    #[test]
    fn test_scrub_password_single_quotes() {
        let input = "password='mysecretpassword123'";
        let result = scrub_credentials(input);
        assert!(result.contains("*[REDACTED]*"));
        assert!(!result.contains("mysecretpassword"));
    }

    #[test]
    fn test_scrub_bearer_header() {
        // Most common pattern: Authorization: Bearer <long-token>
        let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
        let result = scrub_credentials(input);
        assert!(result.contains("*[REDACTED]*"));
        assert!(!result.contains("eyJhbGci"));
    }

    #[test]
    fn test_no_scrub_short_values() {
        let input = r#"{"name": "alex"}"#;
        let result = scrub_credentials(input);
        assert_eq!(result, input);
    }

    #[test]
    fn test_no_scrub_non_sensitive() {
        let input = r#"{"title": "Hello World", "count": 42}"#;
        let result = scrub_credentials(input);
        assert_eq!(result, input);
    }

    #[test]
    fn test_scrub_multiple_credentials() {
        let input = r#"api_key=sk-1234567890 token=ghp_abcdefghij"#;
        let result = scrub_credentials(input);
        assert_eq!(result.matches("*[REDACTED]*").count(), 2);
    }

    #[test]
    fn test_scrub_mixed_case() {
        let input = "API_KEY=sk-ant-1234567890";
        let result = scrub_credentials(input);
        assert!(result.contains("*[REDACTED]*"));
        assert!(!result.contains("sk-ant-1234567890"));
    }

    #[test]
    fn test_scrub_private_key() {
        let input = "private_key=-----BEGIN RSA PRIVATE KEY-----MIIEow";
        let result = scrub_credentials(input);
        assert!(result.contains("*[REDACTED]*"));
    }

    #[test]
    fn test_scrub_preserves_prefix() {
        let input = r#"{"api_key": "sk-ant-1234567890abcdef"}"#;
        let result = scrub_credentials(input);
        assert!(result.contains("sk-a"));
    }
}