arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! MCP output redaction (master Reservation #4).
//!
//! Adds a **result-side** secret scrub for tool output. Every `tools/call`
//! result passes through [`scrub_result`] before it is serialized onto the
//! wire (defense-in-depth at the boundary): a database URL with credentials,
//! an env value, or a connection string is reduced to `[redacted]`. The
//! shipped UAG-reading tools return developer-authored data (route names,
//! paths, handler paths, module names) — none of which matches a secret
//! pattern — so the scrub is a no-op for them in practice; it matters for
//! the deferred DB tools that will return rows or connection diagnostics, but
//! it runs on every result now so a future tool that accidentally returns a
//! secret is caught at the boundary rather than relying on each tool to
//! remember to call it.
//!
//! # What this owns vs what `arcature_observe::redact` owns
//!
//! `arcature_observe::redact` owns the *error-side* discipline: an upstream
//! `Display` that may carry a secret is classified into a fixed
//! `ErrorCategory` and dropped, never relayed. This module owns the
//! *result-side* discipline: a tool's JSON result that may carry a secret is
//! scrubbed before serialization. The two share the `ErrorCategory`
//! vocabulary (used in the error path's tests) so there is one redaction
//! model, not two.
//!
//! # Bounded (§29)
//!
//! The string scrub is a single linear pass with a bounded lookahead; it
//! allocates at most one replacement string. [`scrub_result`] walks the JSON
//! value tree once (depth bounded by the value's own structure, which is
//! developer-authored UAG data, not attacker-controlled). It does not grow
//! with attacker input beyond the input's own size.

#[cfg(test)]
use arcature_observe::redact::ErrorCategory;

/// The literal replaced into a scrubbed secret.
const REDACTED: &str = "[redacted]";

/// Scrub a result string of common secret-bearing patterns:
///
/// * a URL credential segment (`://user:password@host`) → `://[redacted]@host`
/// * a `password=...` / `passwd=...` / `secret=...` / `token=...` /
///   `api_key=...` query/key-value assignment → `password=[redacted]`
/// * an env-style `PASSWORD=...` / `SECRET=...` / `TOKEN=...` assignment.
///
/// Non-overlapping, left-to-right, single pass. Designed for the patterns a
/// DB URL or connection string actually contains, not a general regex
/// engine. The shipped UAG-reading tools never produce these patterns; the
/// scrub runs on every `tools/call` result via [`scrub_result`] so a future
/// tool that returns a secret is caught at the boundary.
pub(crate) fn scrub_value(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    let bytes = value.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        // Match `://user:pass@` → keep `://`, redact through `@`.
        if let Some(at) = url_credential_segment(bytes, i) {
            out.push_str("://");
            out.push_str(REDACTED);
            out.push('@');
            i = at + 1;
            continue;
        }
        // Match a `key=value` secret assignment.
        if let Some(eq) = secret_assignment(bytes, i) {
            // Emit `key=` then redact the value up to the next delimiter.
            out.push_str(std::str::from_utf8(&bytes[i..=eq]).ok().unwrap_or(""));
            out.push_str(REDACTED);
            // Advance past the value: to the next `&`, `;`, whitespace, or end.
            let mut j = eq + 1;
            while j < bytes.len() && !is_value_delim(bytes[j]) {
                j += 1;
            }
            i = j;
            continue;
        }
        // Default: copy one byte.
        out.push(bytes[i] as char);
        i += 1;
    }
    out
}

/// Recursively scrub a JSON `tools/call` result: walk the value tree once and
/// run [`scrub_value`] over every string leaf, replacing each in place. This
/// is the boundary call the dispatcher makes before serializing a result onto
/// the wire (defense-in-depth): the shipped UAG tools return trusted,
/// developer-authored strings that match no secret pattern, so this is a
/// no-op for them in practice — but it runs unconditionally so a deferred tool
/// that returns a DB URL or connection string is protected at the boundary
/// rather than relying on each tool remembering to scrub.
///
/// The walk is bounded by the value's own structure (developer-authored UAG
/// data, not attacker-controlled). Numbers, booleans, and null pass through;
/// arrays and objects are rebuilt with scrubbed children.
pub(crate) fn scrub_result(value: serde_json::Value) -> serde_json::Value {
    use serde_json::Value;
    match value {
        Value::String(s) => Value::String(scrub_value(&s)),
        Value::Array(items) => Value::Array(items.into_iter().map(scrub_result).collect()),
        Value::Object(map) => {
            let scrubbed = map.into_iter().map(|(k, v)| (k, scrub_result(v)));
            Value::Object(scrubbed.collect())
        }
        other => other,
    }
}

/// Detect a `://user:pass@` segment starting at `i` (where `bytes[i..]`
/// begins with `://`). Returns the index of the closing `@` if the segment
/// has a credential (a `:` between `://` and `@`), else `None`.
fn url_credential_segment(bytes: &[u8], i: usize) -> Option<usize> {
    let rest = &bytes[i..];
    if rest.len() < 5 || &rest[..3] != b"://" {
        return None;
    }
    // Find the next `@` within a bounded window (URLs are not unbounded).
    let max = rest.len().min(512);
    let at = rest[..max].iter().position(|&b| b == b'@')?;
    let after_scheme = &rest[3..at];
    // A credential segment has a `:` separating user from password.
    if after_scheme.contains(&b':') && !after_scheme.is_empty() {
        Some(i + at)
    } else {
        None
    }
}

/// Detect a secret key-value assignment at `i`: a known secret key followed
/// by `=`. Returns the index of the `=`. Keys: `password`, `passwd`,
/// `secret`, `token`, `api_key` (case-insensitive on the ASCII letters).
fn secret_assignment(bytes: &[u8], i: usize) -> Option<usize> {
    let rest = &bytes[i..];
    let keys: [&[u8]; 5] = [b"password", b"passwd", b"secret", b"token", b"api_key"];
    for key in keys {
        if rest.len() > key.len() && eq_ascii_ci(&rest[..key.len()], key) && rest[key.len()] == b'='
        {
            // Require a delimiter (or start) before the key so `xpassword=`
            // is not matched as `password=`.
            if i == 0 || is_value_delim(bytes[i - 1]) {
                return Some(i + key.len());
            }
        }
    }
    None
}

fn eq_ascii_ci(a: &[u8], b: &[u8]) -> bool {
    a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.eq_ignore_ascii_case(y))
}

fn is_value_delim(b: u8) -> bool {
    matches!(
        b,
        b'&' | b';' | b' ' | b'\t' | b'\n' | b'\r' | b'"' | b'\'' | b','
    )
}

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

    #[test]
    fn plain_uag_data_is_unaffected() {
        let value = "LinksController::index";
        assert_eq!(scrub_value(value), value);
        assert_eq!(scrub_value("/links/{link}"), "/links/{link}");
        assert_eq!(scrub_value("links.index"), "links.index");
    }

    #[test]
    fn scrubs_url_credential() {
        let value = "postgres://app:s3cr3t@db.example.com:5432/app";
        let scrubbed = scrub_value(value);
        assert!(scrubbed.contains(REDACTED), "{scrubbed}");
        assert!(!scrubbed.contains("s3cr3t"), "{scrubbed}");
        assert!(scrubbed.contains("@db.example.com"), "{scrubbed}");
    }

    #[test]
    fn scrups_url_without_password_is_unaffected() {
        let value = "postgres://db.example.com:5432/app";
        assert_eq!(scrub_value(value), value);
    }

    #[test]
    fn scrubs_password_query_param() {
        let value = "host=db user=app password=hunter2 sslmode=require";
        let scrubbed = scrub_value(value);
        assert!(scrubbed.contains("password=[redacted]"), "{scrubbed}");
        assert!(!scrubbed.contains("hunter2"), "{scrubbed}");
        assert!(scrubbed.contains("user=app"), "{scrubbed}");
    }

    #[test]
    fn scrubs_token_and_secret_and_api_key() {
        for key in ["token", "secret", "api_key", "passwd"] {
            let value = format!("{key}=abc123&other=keep");
            let scrubbed = scrub_value(&value);
            assert!(
                scrubbed.contains(&format!("{key}=[redacted]")),
                "{scrubbed}"
            );
            assert!(!scrubbed.contains("abc123"), "{scrubbed}");
            assert!(scrubbed.contains("other=keep"), "{scrubbed}");
        }
    }

    #[test]
    fn does_not_match_secret_key_as_substring() {
        // `xpassword=` must not be treated as `password=`.
        let value = "xpassword=keep";
        assert_eq!(scrub_value(value), value);
    }

    #[test]
    fn empty_string_is_unaffected() {
        assert_eq!(scrub_value(""), "");
    }

    #[test]
    fn error_category_is_reused_not_reinvented() {
        // The vocabulary is shared with the observe crate (Reservation #4).
        let _ = ErrorCategory::Auth;
        assert_eq!(ErrorCategory::Auth.to_string(), "auth");
    }

    #[test]
    fn scrub_result_walks_nested_json_strings() {
        let value = serde_json::json!({
            "url": "postgres://app:s3cr3t@db.example.com:5432/app",
            "nested": {
                "dsn": "host=db password=hunter2",
                "safe": "LinksController::index",
                "count": 3
            },
            "list": ["token=abc123", "/links/{link}"]
        });
        let scrubbed = scrub_result(value);
        let s = serde_json::to_string(&scrubbed).expect("serialize");
        assert!(!s.contains("s3cr3t"), "{s}");
        assert!(!s.contains("hunter2"), "{s}");
        assert!(!s.contains("abc123"), "{s}");
        assert!(s.contains("[redacted]"), "{s}");
        // Trusted UAG-style data passes through untouched.
        assert!(s.contains("LinksController::index"), "{s}");
        assert!(s.contains("/links/{link}"), "{s}");
        assert!(s.contains("3"), "{s}");
    }

    #[test]
    fn scrub_result_passes_through_non_strings() {
        let value = serde_json::json!({"n": 42, "b": true, "nil": null});
        let scrubbed = scrub_result(value);
        assert_eq!(scrubbed["n"], 42);
        assert_eq!(scrubbed["b"], true);
        assert_eq!(scrubbed["nil"], serde_json::Value::Null);
    }
}