Files
inbuxa-server/crates/features/src/ai/locality.rs
T
jcoffey-dev 9490fc4677 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.
2026-09-19 00:41:00 -07:00

86 lines
2.7 KiB
Rust

/*
* 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"));
}
}