a3s 0.9.8

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use std::collections::HashMap;

use a3s_code_core::config::DocumentParserConfig;
use a3s_code_core::mcp::{McpServerConfig, McpTransportConfig};
use a3s_code_core::ProviderConfig;
use serde::Serialize;
use serde_json::{json, Value};

pub(super) const REDACTED_SECRET: &str = "[configured]";

pub(super) fn redact_secret(value: Option<&str>) -> Option<&'static str> {
    value
        .filter(|secret| !secret.trim().is_empty())
        .map(|_| REDACTED_SECRET)
}

pub(super) fn redact_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
    headers
        .iter()
        .map(|(key, value)| {
            if is_sensitive_key(key) && !value.trim().is_empty() {
                (key.clone(), REDACTED_SECRET.to_string())
            } else {
                (key.clone(), value.clone())
            }
        })
        .collect()
}

pub(super) fn sanitized_json_value<T: Serialize>(value: &T) -> Value {
    let mut value = serde_json::to_value(value).unwrap_or_else(|_| json!({}));
    redact_secrets_in_value(&mut value);
    value
}

pub(super) fn preserve_provider_secrets(
    providers: &mut [ProviderConfig],
    existing: &[ProviderConfig],
) {
    for provider in providers {
        let Some(previous) = existing.iter().find(|item| item.name == provider.name) else {
            continue;
        };
        preserve_secret(&mut provider.api_key, previous.api_key.as_deref());
        preserve_map_placeholders(&mut provider.headers, &previous.headers);
        for model in &mut provider.models {
            let Some(previous_model) = previous.models.iter().find(|item| item.id == model.id)
            else {
                continue;
            };
            preserve_secret(&mut model.api_key, previous_model.api_key.as_deref());
            preserve_map_placeholders(&mut model.headers, &previous_model.headers);
        }
    }
}

pub(super) fn preserve_mcp_secrets(servers: &mut [McpServerConfig], existing: &[McpServerConfig]) {
    for server in servers {
        let Some(previous) = existing.iter().find(|item| item.name == server.name) else {
            continue;
        };
        preserve_map_placeholders(&mut server.env, &previous.env);
        preserve_mcp_transport_secrets(&mut server.transport, &previous.transport);
        if let (Some(oauth), Some(previous_oauth)) =
            (server.oauth.as_mut(), previous.oauth.as_ref())
        {
            preserve_secret(
                &mut oauth.client_secret,
                previous_oauth.client_secret.as_deref(),
            );
            preserve_secret(
                &mut oauth.access_token,
                previous_oauth.access_token.as_deref(),
            );
        }
    }
}

pub(super) fn preserve_document_parser_secret(
    parser: &mut DocumentParserConfig,
    existing: Option<&DocumentParserConfig>,
) {
    let (Some(ocr), Some(previous_ocr)) = (
        parser.ocr.as_mut(),
        existing.and_then(|parser| parser.ocr.as_ref()),
    ) else {
        return;
    };
    preserve_secret(&mut ocr.api_key, previous_ocr.api_key.as_deref());
}

pub(super) fn preserve_secret(value: &mut Option<String>, previous: Option<&str>) {
    if value.as_deref().map(str::trim) == Some(REDACTED_SECRET) {
        *value = previous.map(str::to_string);
    }
}

fn preserve_mcp_transport_secrets(
    transport: &mut McpTransportConfig,
    previous: &McpTransportConfig,
) {
    match (transport, previous) {
        (
            McpTransportConfig::Http { headers, .. },
            McpTransportConfig::Http {
                headers: previous_headers,
                ..
            },
        )
        | (
            McpTransportConfig::StreamableHttp { headers, .. },
            McpTransportConfig::StreamableHttp {
                headers: previous_headers,
                ..
            },
        ) => preserve_map_placeholders(headers, previous_headers),
        _ => {}
    }
}

fn preserve_map_placeholders(
    values: &mut HashMap<String, String>,
    previous: &HashMap<String, String>,
) {
    for (key, value) in values {
        if value.trim() == REDACTED_SECRET {
            if let Some(previous) = previous.get(key) {
                *value = previous.clone();
            }
        }
    }
}

fn redact_secrets_in_value(value: &mut Value) {
    match value {
        Value::Object(object) => {
            for (key, child) in object.iter_mut() {
                if is_sensitive_key(key)
                    && child.as_str().is_some_and(|text| !text.trim().is_empty())
                {
                    *child = Value::String(REDACTED_SECRET.to_string());
                } else {
                    redact_secrets_in_value(child);
                }
            }
        }
        Value::Array(items) => {
            for child in items {
                redact_secrets_in_value(child);
            }
        }
        _ => {}
    }
}

fn is_sensitive_key(key: &str) -> bool {
    let normalized = key
        .chars()
        .filter(|ch| ch.is_ascii_alphanumeric())
        .flat_map(|ch| ch.to_lowercase())
        .collect::<String>();
    normalized.contains("apikey")
        || normalized.contains("authorization")
        || normalized.contains("accesstoken")
        || normalized.contains("refreshtoken")
        || normalized.contains("clientsecret")
        || normalized.contains("secretkey")
        || normalized == "token"
        || normalized == "password"
        || normalized.ends_with("token")
        || normalized.ends_with("secret")
}

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

    #[test]
    fn redaction_never_exposes_nested_tokens() {
        let value = json!({
            "apiKey": "secret",
            "headers": { "Authorization": "Bearer secret", "X-Tenant": "a3s" },
            "oauth": { "client_secret": "oauth-secret" },
        });

        let masked = sanitized_json_value(&value);
        let serialized = serde_json::to_string(&masked).expect("masked JSON");
        assert!(!serialized.contains("Bearer secret"));
        assert!(!serialized.contains("oauth-secret"));
        assert_eq!(masked["headers"]["X-Tenant"], "a3s");
    }
}