ljos 0.35.5

Memory, a work tracker and a shell-command gate for coding agents (Claude Code, Codex, Grok Build, agy, opencode) over MCP and hooks.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! 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 (default-ignorable characters removed, NFKC, lower case),
//! so a full-width letter, a zero-width space or a variation selector does
//! not slip a term past.
//! The term, pair and attribution scans read the body twice, as written
//! and as a client that sniffs HTML would show it, and refuse when
//! either hits. A draft the checks cannot read, such as a base64 body,
//! is refused rather than skipped, as is one the MTA could rewrite: a
//! byte that is not ASCII or a line over 998 octets.

use std::io::Read as _;
use std::path::Path;

use anyhow::{bail, Context as _, Result};
use icu_properties::{props::DefaultIgnorableCodePoint, CodePointSetData};
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 {
    /// The deny file at `path`, opened by `config::open_owned`.
    pub fn read(path: &Path) -> Result<Self> {
        let (mut file, _) = super::config::open_owned(path, "checks: deny_file")?
            .with_context(|| format!("checks: the deny file {} does not exist", path.display()))?;
        let mut text = String::new();
        file.read_to_string(&mut text)
            .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(sent_as_is(&message));
    out.extend(plain_text_only(&headers, body));

    let head = headers
        .iter()
        .map(|(n, v)| format!("{n}: {v}"))
        .collect::<Vec<_>>()
        .join("\n");
    let views = [body.to_string(), rendered(body)].map(|b| fold(&format!("{head}\n{b}")));
    let carries = |t: &String| views.iter().any(|v| has_word(v, t));
    if deny
        .terms
        .iter()
        .chain(&deny.addresses)
        .chain(&deny.domains)
        .chain(&deny.handles)
        .any(carries)
    {
        out.push("checks: the draft carries a denied term".into());
    }
    if deny
        .pairs
        .iter()
        .any(|(a, b)| views.iter().any(|v| has_word(v, a) && has_word(v, b)))
    {
        out.push("checks: the draft carries a denied pair".into());
    }
    if let Some(hit) = attribution.iter().find(|p| carries(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
}

/// The MTA delivers exactly the bytes `H` names only when every byte is
/// ASCII and no line is over 998 octets. Postfix's `sendmail` splits a
/// longer line and the SMTP client folds at 998, and an 8bit body is
/// rewritten to quoted-printable for a next hop without `8BITMIME`.
fn sent_as_is(message: &str) -> Vec<String> {
    let mut out = Vec::new();
    if let Some(n) = message.split('\n').position(|line| line.len() > 998) {
        out.push(format!(
            "checks: line {} is over 998 octets, so the MTA would break it",
            n + 1
        ));
    }
    if !message.is_ascii() {
        out.push("checks: the draft is not ASCII, so an MTA may re-encode it; send 7bit".into());
    }
    out
}

/// What a recipient's client shows is what the checks scanned only for a
/// single `text/plain; charset=utf-8` part in 7bit. Anything
/// else (multipart, HTML, base64, quoted-printable, encoded words, a
/// closing tag, comment or declaration, or an entity) 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") {
        out.push(
            "checks: send 7bit; an encoded body cannot be scanned and an 8bit one may be re-encoded"
                .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 opens a closing tag, comment or declaration, or an `&`
/// that starts a named or numeric entity. An opening tag such as
/// `<https://...>` is left to the scan of `rendered`.
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'<' => 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,
        }
    })
}

/// The body as a client that sniffs HTML would show it: every `<...>`
/// removed, then numeric references, with or without their `;`, and
/// `&amp`, `&lt`, `&gt`, `&quot`, `&apos` and `&nbsp` decoded.
fn rendered(body: &str) -> String {
    let mut text = String::with_capacity(body.len());
    let mut rest = body;
    while let Some(lt) = rest.find('<') {
        text.push_str(&rest[..lt]);
        rest = &rest[lt..];
        let Some(gt) = rest.find('>') else { break };
        rest = &rest[gt + 1..];
    }
    text.push_str(rest);
    let mut out = String::with_capacity(text.len());
    let mut rest = text.as_str();
    while let Some(amp) = rest.find('&') {
        out.push_str(&rest[..amp]);
        rest = &rest[amp + 1..];
        let (c, len) = reference(rest).unwrap_or(('&', 0));
        out.push(c);
        rest = &rest[len..];
    }
    out.push_str(rest);
    out
}

/// The character a reference after `&` stands for, and its length.
fn reference(s: &str) -> Option<(char, usize)> {
    const NAMES: &[(&str, char)] = &[
        ("amp", '&'),
        ("lt", '<'),
        ("gt", '>'),
        ("quot", '"'),
        ("apos", '\''),
        ("nbsp", '\u{a0}'),
    ];
    let semi = |s: &str| usize::from(s.starts_with(';'));
    let Some(n) = s.strip_prefix('#') else {
        let (name, c) = NAMES.iter().find(|(name, _)| {
            s.get(..name.len())
                .is_some_and(|p| p.eq_ignore_ascii_case(name))
        })?;
        return Some((*c, name.len() + semi(&s[name.len()..])));
    };
    let (digits, radix) = match n.strip_prefix(['x', 'X']) {
        Some(hex) => (hex, 16),
        None => (n, 10),
    };
    let run = digits
        .find(|c: char| !c.is_digit(radix))
        .unwrap_or(digits.len());
    if run == 0 {
        return None;
    }
    let c = u32::from_str_radix(&digits[..run], radix)
        .ok()
        .and_then(char::from_u32)
        .filter(|&c| c != '\0')
        .unwrap_or('\u{FFFD}');
    Some((c, s.len() - digits.len() + run + semi(&digits[run..])))
}

/// 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
}

/// Default_Ignorable_Code_Point characters removed, then NFKC, then
/// lower case. NFKC keeps variation selectors and tag characters, so a
/// term split by one would otherwise not match.
fn fold(text: &str) -> String {
    let ignorable = CodePointSetData::new::<DefaultIgnorableCodePoint>();
    let visible: String = text.chars().filter(|c| !ignorable.contains(*c)).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)
    })
}