AI spam classification: the model's opinion as one bounded spam signal, and the llm_prompt Sieve function (AI-1 to AI-28)
The classifier sends only the subject and text, between unforgeable markers after the operator's prompt, to an OpenAI-compatible endpoint the operator configured; nothing is preset. Its answer maps to an LLM_ tag whose score is clamped (+5.0, -1.0 by default) and can never discard or reject on its own; X-Spam-LLM is sanitized, encoded and folded, and a planted one is removed. Failures, timeouts past the ceiling, a full slot or a paused model leave mail flowing untagged. llm_prompt answers trusted scripts, and accounts holding interactAi within an hourly limit. Redirects aren't followed and no content or secret is logged. The limits live in inbuxa:AiLimits. Acceptance tests 1 and 3 to 21; test 2 as the re-enabled shared llm case, whose setup no longer waits on a rules file from a developer's own path; test 22 written as the ignored ai_compat.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Whether a model's endpoint keeps message content on this network (AI-2).
|
||||
//! Advisory only: it decides a warning, never whether a call is made.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// The host in a URL, without brackets, userinfo or port.
|
||||
pub fn host(url: &str) -> Option<&str> {
|
||||
let rest = url.split_once("://")?.1;
|
||||
let authority = rest.split(['/', '?', '#']).next()?;
|
||||
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||
let host = if let Some(bracketed) = authority.strip_prefix('[') {
|
||||
bracketed.split_once(']')?.0
|
||||
} else {
|
||||
authority.split(':').next()?
|
||||
};
|
||||
(!host.is_empty()).then_some(host)
|
||||
}
|
||||
|
||||
/// Loopback, RFC 1918, RFC 4193 (and link-local) addresses.
|
||||
pub fn is_local_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
|
||||
IpAddr::V6(ip) => {
|
||||
ip.is_loopback()
|
||||
|| (ip.segments()[0] & 0xfe00) == 0xfc00
|
||||
|| (ip.segments()[0] & 0xffc0) == 0xfe80
|
||||
|| ip.to_ipv4_mapped().is_some_and(|v4| is_local_ip(IpAddr::V4(v4)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a URL's host says before any name lookup: `Some(true)` local,
|
||||
/// `Some(false)` not, `None` a name to resolve.
|
||||
pub fn classify(url: &str) -> Option<bool> {
|
||||
let Some(host) = host(url) else {
|
||||
return Some(false);
|
||||
};
|
||||
if host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().ends_with(".localhost")
|
||||
{
|
||||
return Some(true);
|
||||
}
|
||||
match host.parse::<IpAddr>() {
|
||||
Ok(ip) => Some(is_local_ip(ip)),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning's text (AI-2).
|
||||
pub fn warning(model: &str, url: &str) -> String {
|
||||
format!(
|
||||
"AI model {model:?} points at {url}, which isn't on this network: message content sent \
|
||||
to it leaves this network."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_hosts() {
|
||||
for local in [
|
||||
"http://127.0.0.1:8080/v1/chat/completions",
|
||||
"http://localhost/v1",
|
||||
"https://10.0.0.5/v1",
|
||||
"https://192.168.1.2:443/x",
|
||||
"http://[::1]:8080/v1",
|
||||
"http://[fd12:3456::1]/v1",
|
||||
"http://user:[email protected]/v1",
|
||||
] {
|
||||
assert_eq!(classify(local), Some(true), "{local}");
|
||||
}
|
||||
for remote in ["https://8.8.8.8/v1", "http://[2001:db8::1]/v1", "not a url"] {
|
||||
assert_eq!(classify(remote), Some(false), "{remote}");
|
||||
}
|
||||
assert_eq!(classify("https://mail.example.net/v1"), None);
|
||||
assert_eq!(host("https://mail.example.net:8443/v1?x"), Some("mail.example.net"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user