ljos 0.33.0

Memory, a work tracker and a shell-command gate for coding agents (Claude Code, Codex, Grok Build, agy, opencode) over MCP and hooks.
Documentation
//! Identity, term and attribution checks on a draft. They only refuse.
//!
//! An empty list of refusals grants nothing: a draft still needs the
//! ballot, the policy check and the owner's yes. Every comparison runs on
//! folded text (invisible format characters removed, NFKC, lower case), so
//! a full-width letter or a zero-width space does not slip a term past.
//! A draft the checks cannot read, such as a base64 body, is refused
//! rather than skipped.

use std::path::Path;

use anyhow::{bail, Context as _, Result};
use unicode_normalization::UnicodeNormalization as _;

use super::config::MailConfig;

/// Attribution any user would want refused. `[mail] attribution` adds more.
const ATTRIBUTION: &[&str] = &[
    "generated by",
    "generated with",
    "generated using",
    "co-authored-by",
    "as an ai",
    "as a language model",
    "large language model",
    "ai assistant",
    "ai-generated",
    "ai generated",
    "chatgpt",
    "openai",
    "anthropic",
    "claude",
    "gpt-4",
    "gpt-4o",
    "gpt-5",
    "copilot",
    "gemini",
];

/// Words that mark a mailer header as a tool's rather than a person's.
const AGENT_MAILER: &[&str] = &["agent", "bot", "ai", "llm", "assistant", "automated"];

/// The deny file: `kind: value` per line, `#` starts a comment.
#[derive(Debug, Clone, Default)]
pub struct DenyList {
    addresses: Vec<String>,
    domains: Vec<String>,
    handles: Vec<String>,
    terms: Vec<String>,
    pairs: Vec<(String, String)>,
}

impl DenyList {
    pub fn read(path: &Path) -> Result<Self> {
        let text = std::fs::read_to_string(path)
            .with_context(|| format!("checks: cannot read the deny file {}", path.display()))?;
        Self::parse(&text)
    }

    pub fn parse(text: &str) -> Result<Self> {
        let mut deny = Self::default();
        for (n, line) in text.lines().enumerate() {
            let line = line.split('#').next().unwrap_or("").trim();
            if line.is_empty() {
                continue;
            }
            let Some((kind, value)) = line.split_once(':') else {
                bail!("checks: deny file line {} has no kind", n + 1);
            };
            let value = fold(value.trim());
            if value.is_empty() {
                bail!("checks: deny file line {} has no value", n + 1);
            }
            match kind.trim() {
                "address" => deny.addresses.push(value),
                "domain" => deny.domains.push(value.trim_start_matches('@').to_string()),
                "handle" => deny.handles.push(value),
                "term" => deny.terms.push(value),
                "pair" => match value.split_once('+') {
                    Some((a, b)) if !a.trim().is_empty() && !b.trim().is_empty() => {
                        deny.pairs
                            .push((a.trim().to_string(), b.trim().to_string()));
                    }
                    _ => bail!("checks: deny file line {} pair is not `a + b`", n + 1),
                },
                other => bail!("checks: deny file line {} has unknown kind {other}", n + 1),
            }
        }
        Ok(deny)
    }

    /// A trailing dot on the domain is the same domain to DNS, so it is
    /// dropped before matching.
    fn denies_address(&self, address: &str) -> bool {
        let address = address.trim_end_matches('.');
        let domain = address.rsplit_once('@').map_or("", |(_, d)| d);
        self.addresses.iter().any(|a| a == address)
            || self
                .domains
                .iter()
                .any(|d| domain == d || domain.ends_with(&format!(".{d}")))
    }
}

/// The refusals for one draft under the `[mail]` config. Config that
/// cannot be read is a refusal too, so a broken setup never passes.
#[must_use]
pub fn refusals(message: &[u8], config: &MailConfig) -> Vec<String> {
    let Some(identity) = config.identity.as_deref() else {
        return vec!["checks: no [mail] identity is configured".into()];
    };
    let Some(deny_file) = config.deny_file.as_deref() else {
        return vec!["checks: no [mail] deny_file is configured".into()];
    };
    let deny = match DenyList::read(deny_file) {
        Ok(deny) => deny,
        Err(e) => return vec![format!("{e:#}")],
    };
    let attribution: Vec<String> = ATTRIBUTION
        .iter()
        .map(|p| fold(p))
        .chain(config.attribution.iter().map(|p| fold(p)))
        .collect();
    check(message, &fold(identity), &deny, &attribution)
}

fn check(message: &[u8], identity: &str, deny: &DenyList, attribution: &[String]) -> Vec<String> {
    let Ok(message) = std::str::from_utf8(message) else {
        return vec!["checks: the draft is not UTF-8".into()];
    };
    let message = message.replace("\r\n", "\n");
    let (head, body) = message.split_once("\n\n").unwrap_or((&message, ""));
    let Some(headers) = unfold(head) else {
        return vec!["checks: a header line is neither a field nor a continuation".into()];
    };
    let mut out = Vec::new();
    let values = |name: &str| -> Vec<&str> {
        headers
            .iter()
            .filter(|(n, _)| n == name)
            .map(|(_, v)| v.as_str())
            .collect()
    };

    let from: Vec<String> = values("from").iter().flat_map(|v| addresses(v)).collect();
    if from.len() != 1 || from[0] != identity {
        out.push(format!("checks: From must be exactly {identity}"));
    }
    for name in ["reply-to", "sender", "return-path"] {
        for value in values(name) {
            let folded = fold(value);
            if addresses(value).iter().any(|a| deny.denies_address(a))
                || deny.handles.iter().any(|h| has_word(&folded, h))
            {
                out.push(format!("checks: {name} names a denied identity"));
            }
        }
    }
    for name in ["to", "cc", "bcc"] {
        for address in values(name).iter().flat_map(|v| addresses(v)) {
            if deny.denies_address(&address) {
                out.push(format!("checks: {name} has a denied recipient"));
            }
        }
    }

    out.extend(plain_text_only(&headers, body));

    let text = fold(&format!(
        "{}\n{body}",
        headers
            .iter()
            .map(|(n, v)| format!("{n}: {v}"))
            .collect::<Vec<_>>()
            .join("\n")
    ));
    let identities = deny
        .addresses
        .iter()
        .chain(&deny.domains)
        .chain(&deny.handles);
    if deny
        .terms
        .iter()
        .chain(identities)
        .any(|t| has_word(&text, t))
    {
        out.push("checks: the draft carries a denied term".into());
    }
    if deny
        .pairs
        .iter()
        .any(|(a, b)| has_word(&text, a) && has_word(&text, b))
    {
        out.push("checks: the draft carries a denied pair".into());
    }
    if let Some(hit) = attribution.iter().find(|p| has_word(&text, p)) {
        out.push(format!("checks: the draft carries attribution ({hit})"));
    }
    for name in ["x-mailer", "user-agent"] {
        for value in values(name) {
            let folded = fold(value);
            if AGENT_MAILER.iter().any(|w| has_word(&folded, w)) {
                out.push(format!(
                    "checks: {name} names a tool, not a person's mailer"
                ));
            }
        }
    }
    out
}

/// What a recipient's client shows is what the checks scanned only for a
/// single `text/plain; charset=utf-8` part in 7bit or 8bit. Anything
/// else (multipart, HTML, base64, quoted-printable, encoded words,
/// markup or entities a client might render) is refused.
fn plain_text_only(headers: &[(String, String)], body: &str) -> Vec<String> {
    let mut out = Vec::new();
    let compact = |v: &str| -> String {
        v.chars()
            .filter(|c| !c.is_whitespace())
            .collect::<String>()
            .to_ascii_lowercase()
    };
    let all = |name: &str| -> Vec<String> {
        headers
            .iter()
            .filter(|(n, _)| n == name)
            .map(|(_, v)| compact(v))
            .collect()
    };
    let types = all("content-type");
    if types.len() != 1
        || !matches!(
            types[0].as_str(),
            "text/plain;charset=utf-8" | "text/plain;charset=\"utf-8\""
        )
    {
        out.push("checks: the draft must be one text/plain; charset=utf-8 part".into());
    }
    let encodings = all("content-transfer-encoding");
    if encodings.len() > 1 || encodings.iter().any(|e| e != "7bit" && e != "8bit") {
        out.push("checks: encoded text cannot be scanned; send it as 7bit or 8bit UTF-8".into());
    }
    if body.contains("=?") || headers.iter().any(|(_, v)| v.contains("=?")) {
        out.push("checks: encoded words cannot be scanned; send plain UTF-8".into());
    }
    if has_markup(body) {
        out.push("checks: the body carries HTML tags or entities".into());
    }
    out
}

/// A `<` that could open a tag, comment or declaration, or an `&` that
/// starts a named or numeric entity.
fn has_markup(body: &str) -> bool {
    let bytes = body.as_bytes();
    bytes.iter().enumerate().any(|(i, &b)| {
        let next = bytes.get(i + 1).copied().unwrap_or(0);
        match b {
            b'<' => next.is_ascii_alphabetic() || matches!(next, b'/' | b'!' | b'?'),
            b'&' => {
                let name: Vec<u8> = bytes[i + 1..]
                    .iter()
                    .copied()
                    .take_while(|c| c.is_ascii_alphanumeric() || *c == b'#')
                    .collect();
                !name.is_empty() && bytes.get(i + 1 + name.len()) == Some(&b';')
            }
            _ => false,
        }
    })
}

/// Header fields unfolded as RFC 5322 says (a line break before
/// whitespace is removed), names in lower case. `None` when a line is
/// neither a field nor a continuation, so nothing is silently skipped.
pub(crate) fn unfold(head: &str) -> Option<Vec<(String, String)>> {
    let mut headers: Vec<(String, String)> = Vec::new();
    for line in head.lines() {
        if line.starts_with([' ', '\t']) {
            headers.last_mut()?.1.push_str(line);
        } else {
            let (name, value) = line.split_once(':')?;
            if name.is_empty() || !name.bytes().all(|b| b.is_ascii_graphic()) {
                return None;
            }
            headers.push((name.to_ascii_lowercase(), value.to_string()));
        }
    }
    for (_, value) in &mut headers {
        *value = value.trim().to_string();
    }
    Some(headers)
}

/// The folded addresses in one address header, display names dropped.
fn addresses(value: &str) -> Vec<String> {
    let mut out = Vec::new();
    let (mut quoted, mut angle, mut item) = (false, false, String::new());
    for c in value.chars().chain(std::iter::once(',')) {
        match c {
            '"' if !angle => quoted = !quoted,
            '<' if !quoted => angle = true,
            '>' if !quoted => angle = false,
            ',' if !quoted && !angle => {
                let raw = std::mem::take(&mut item);
                let address = match (raw.find('<'), raw.rfind('>')) {
                    (Some(a), Some(b)) if a < b => raw[a + 1..b].to_string(),
                    _ => raw.trim().to_string(),
                };
                let address = fold(address.trim());
                if !address.is_empty() {
                    out.push(address);
                }
                continue;
            }
            _ => {}
        }
        item.push(c);
    }
    out
}

/// Invisible format characters removed, then NFKC, then lower case.
fn fold(text: &str) -> String {
    let visible: String = text
        .chars()
        .filter(|c| {
            !matches!(c,
                '\u{00AD}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}'
                | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
        })
        .collect();
    visible.nfkc().collect::<String>().to_lowercase()
}

/// `needle` in `hay` with no letter or digit on either side.
fn has_word(hay: &str, needle: &str) -> bool {
    if needle.is_empty() {
        return false;
    }
    hay.match_indices(needle).any(|(at, _)| {
        let before = hay[..at].chars().next_back();
        let after = hay[at + needle.len()..].chars().next();
        !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric)
    })
}