Skip to main content

vector_core/community/policy/
presets.rs

1//! The preset library: what an admin picks instead of writing a document.
2//!
3//! Every preset is a real policy — the same bytes the engine evaluates and the
4//! validator polices — so "pick a template" and "write JSON" produce the same
5//! artifact. The dials a designer shows are the only things a preset varies,
6//! which keeps the numbers in ONE place (here) rather than scattered across a
7//! UI that could drift from the engine.
8
9use super::document::*;
10use super::types::Severity;
11
12/// A dial the designer renders, and what it edits inside the policy.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct Dial {
15    pub key: &'static str,
16    pub label: &'static str,
17    pub kind: &'static str, // "strictness" | "wordlist" | "domainlist" | "channels" | "summary" | "text" | "rules" | "seconds"
18    pub hint: &'static str,
19}
20
21#[derive(Debug, Clone, serde::Serialize)]
22pub struct Preset {
23    pub id: &'static str,
24    pub name: &'static str,
25    /// Plain-language: what it catches, in the words an admin would use.
26    pub description: &'static str,
27    /// A concrete example, so nobody has to imagine what it means.
28    pub example: &'static str,
29    /// What it CANNOT do on its own — stated up front, because a preset that
30    /// only advertises its powers reads as a promise it may not keep.
31    pub caveat: &'static str,
32    pub dials: Vec<Dial>,
33    /// The starting document, as bytes the engine would evaluate.
34    pub policy: Policy,
35}
36
37fn tiers(per_message: Vec<Rung>, per_window: Vec<Rung>) -> Option<Tiers> {
38    Some(Tiers { per_message, per_window })
39}
40
41fn rung(hits: u32, severity: Severity, weight: u32) -> Rung {
42    Rung { hits, severity, weight, pierces_trusted: false }
43}
44
45fn rule(id: &str, matcher: Match, t: Option<Tiers>) -> Rule {
46    Rule {
47        id: id.into(),
48        matcher,
49        tiers: t,
50        severity: None,
51        weight: None,
52        pierces_trusted: false,
53        family: None,
54        armed_by: None,
55        exempt: Exempt::default(),
56        enforcement: Enforcement::Advisory,
57    }
58}
59
60fn single(id: &str, matcher: Match, severity: Severity, weight: u32, armed_by: Option<ArmedBy>) -> Rule {
61    Rule {
62        id: id.into(),
63        matcher,
64        tiers: None,
65        severity: Some(severity),
66        weight: Some(weight),
67        pierces_trusted: false,
68        family: None,
69        armed_by,
70        exempt: Exempt::default(),
71        enforcement: Enforcement::Advisory,
72    }
73}
74
75fn base(name: &str, rules: Vec<Rule>, hours: u64) -> Policy {
76    Policy {
77        format: FORMAT,
78        requires: vec![],
79        name: name.into(),
80        emoji_codes: vec![],
81        shields: Shields::default(),
82        window: Window { hours, max_messages: 4000 },
83        exempt: Exempt::default(),
84        rules,
85    }
86}
87
88/// Link shorteners and redirectors a scam campaign hides behind. Bundled with
89/// the build — a moderation feature must not phone home — so it is only as
90/// fresh as the release.
91pub const SHORTENERS: &[&str] = &[
92    "bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd", "buff.ly", "adf.ly", "bit.do", "cutt.ly",
93    "rebrand.ly", "shorturl.at", "rb.gy", "tiny.cc", "shorte.st", "bc.vc", "clck.ru", "soo.gd", "s2r.co",
94    "tr.ee", "dub.sh", "e.vg", "paw.wf", "shm.to", "snl.ink", "surl.li", "url9.de", "waffl.link",
95];
96
97/// One rule, in the words an admin would use.
98///
99/// The engine's own vocabulary — matcher variants, rungs, weights — is exactly
100/// what makes a shipped policy read as a black box. This is the only place
101/// that translation lives, so a console, a bot and a CLI all describe the same
102/// rule the same way.
103#[derive(Debug, Clone, serde::Serialize)]
104pub struct RuleSummary {
105    pub label: String,
106    pub detail: String,
107    /// True when the rule stays silent until another rule has already
108    /// convicted. Unmarked, an aggravator reads as cover it does not provide.
109    pub armed: bool,
110}
111
112fn plural(n: u32, one: &str, many: &str) -> String {
113    if n == 1 { format!("{n} {one}") } else { format!("{n} {many}") }
114}
115
116fn describe_thresholds(r: &Rule) -> String {
117    // A burst-bounded repeat already named its span, so "in the window" would
118    // point at a different stretch of time than the sentence before it.
119    let window_scoped = !matches!(r.matcher, Match::Repeat { within_secs: Some(_), .. });
120    if let Some(t) = &r.tiers {
121        let mut parts: Vec<String> = Vec::new();
122        for rung in &t.per_message {
123            parts.push(format!("{} in one message", plural(rung.hits, "hit", "hits")));
124        }
125        for rung in &t.per_window {
126            parts.push(if window_scoped {
127                format!("{} in the window", plural(rung.hits, "hit", "hits"))
128            } else {
129                plural(rung.hits, "hit", "hits")
130            });
131        }
132        if !parts.is_empty() {
133            return format!("Trips at {}.", parts.join(", then "));
134        }
135    }
136    String::new()
137}
138
139/// Describe every rule in a policy, in order.
140pub fn describe(policy: &Policy) -> Vec<RuleSummary> {
141    policy.rules.iter().map(describe_rule).collect()
142}
143
144pub fn describe_rule(r: &Rule) -> RuleSummary {
145    let (label, mut detail) = match &r.matcher {
146        Match::Keyword { patterns, .. } => (
147            "Words".to_string(),
148            format!(
149                "{} on the list, matched as whole words unless you wrap them in *.",
150                plural(patterns.len() as u32, "word", "words")
151            ),
152        ),
153        Match::Regex { patterns, .. } => (
154            "Patterns".to_string(),
155            plural(patterns.len() as u32, "expression", "expressions") + " matched against each message.",
156        ),
157        Match::Link { patterns } => (
158            "Link domains".to_string(),
159            format!(
160                "{} blocked, subdomains included.",
161                plural(patterns.len() as u32, "domain", "domains")
162            ),
163        ),
164        Match::Repeat { within_secs, .. } => (
165            "Same message repeated".to_string(),
166            match within_secs {
167                Some(secs) => format!(
168                    "One account posting the same thing over and over inside {} minutes. Case, punctuation and digits are ignored, so changing them does not help.",
169                    secs / 60
170                ),
171                None => "One account posting the same thing over and over. Case, punctuation and digits are ignored, so changing them does not help.".to_string(),
172            },
173        ),
174        Match::Rate { per_secs } => (
175            "Posting too fast".to_string(),
176            format!(
177                "One account's messages counted over any {} seconds, whatever they say.",
178                per_secs
179            ),
180        ),
181        Match::Mentions {} => (
182            "Mass tagging".to_string(),
183            "How many DISTINCT people one message names. Tagging the same person repeatedly counts once.".to_string(),
184        ),
185        Match::Cohort { min, .. } => (
186            "Many accounts, one line".to_string(),
187            format!(
188                "{} or more separate accounts posting the same line at the same time. The shape of a raid.",
189                min
190            ),
191        ),
192        Match::JoinBurst { gap_secs, min } => (
193            "Join flood".to_string(),
194            format!("{} or more joins within {} minutes.", min, gap_secs / 60),
195        ),
196        Match::TenureLt { secs } => (
197            "New account".to_string(),
198            format!("Joined less than {} hours ago.", secs / 3600),
199        ),
200        Match::MessagesLte { n } => (
201            "Barely posted".to_string(),
202            format!("Has posted at most {}.", plural(*n, "message", "messages")),
203        ),
204    };
205    let t = describe_thresholds(r);
206    if !t.is_empty() {
207        detail.push(' ');
208        detail.push_str(&t);
209    }
210    RuleSummary { label, detail, armed: r.armed_by.is_some() }
211}
212
213/// One rule a from-scratch policy may add: what it catches, what the author has
214/// to supply, and the starting numbers.
215#[derive(Debug, Clone, serde::Serialize)]
216pub struct RuleKind {
217    pub id: &'static str,
218    pub label: &'static str,
219    /// Plain-language: what trips it.
220    pub description: &'static str,
221    /// What the author supplies: "wordlist" | "domainlist" | "none".
222    pub input: &'static str,
223    pub input_label: &'static str,
224    pub input_hint: &'static str,
225    /// The starting rule, weights and rungs included, so a builder never has to
226    /// invent numbers the engine would then have to honour.
227    pub rule: Rule,
228}
229
230/// The rule kinds a from-scratch policy may add.
231///
232/// Deliberately NOT the whole `Match` set. `TenureLt` and `MessagesLte`
233/// describe most of a healthy community on their own, so they exist only as
234/// aggravators armed behind a real conviction and the builder never offers
235/// them loose. Every kind here can stand as the only rule in a policy and
236/// still convict the right person.
237pub fn rule_kinds() -> Vec<RuleKind> {
238    vec![
239        RuleKind {
240            id: "keyword",
241            label: "Words",
242            description: "Someone posts a word from your list.",
243            input: "wordlist",
244            input_label: "Words to catch",
245            input_hint: "One per line. Use *word* to match inside longer words.",
246            rule: rule(
247                "words",
248                Match::Keyword { patterns: vec![], normalize: Normalize::Fold },
249                tiers(vec![rung(1, Severity::Minor, 10)], vec![rung(10, Severity::Major, 45)]),
250            ),
251        },
252        RuleKind {
253            id: "link",
254            label: "Link domains",
255            description: "Someone posts a link to a domain on your list.",
256            input: "domainlist",
257            input_label: "Domains to block",
258            input_hint: "One per line. Matches the domain and its subdomains.",
259            rule: rule(
260                "links",
261                Match::Link { patterns: vec![] },
262                tiers(vec![rung(1, Severity::Severe, 70)], vec![rung(3, Severity::Severe, 90)]),
263            ),
264        },
265        RuleKind {
266            id: "repeat",
267            label: "Same message repeated",
268            description: "One account posting the same thing over and over inside half an hour. Case, punctuation and digits are ignored, so changing them does not help.",
269            input: "none",
270            input_label: "",
271            input_hint: "",
272            rule: rule(
273                "repeat",
274                Match::Repeat { normalize: Normalize::Skeleton, within_secs: Some(super::harness::REPEAT_BURST_SECS) },
275                tiers(vec![], vec![rung(4, Severity::Major, 50), rung(8, Severity::Severe, 85)]),
276            ),
277        },
278        RuleKind {
279            id: "mentions",
280            label: "Mass tagging",
281            description: "One message naming a crowd. Counts distinct people, so twenty pings at one person is one person — annoying, but not a raid.",
282            input: "none",
283            input_label: "",
284            input_hint: "",
285            rule: rule(
286                "mentions",
287                Match::Mentions {},
288                tiers(vec![rung(6, Severity::Major, 50), rung(12, Severity::Severe, 85)], vec![]),
289            ),
290        },
291        RuleKind {
292            id: "rate",
293            label: "Posting too fast",
294            description: "One account posting faster than a person reasonably types, whatever they are saying. Catches a spammer who varies their text just enough to look different every time.",
295            input: "seconds",
296            input_label: "Over how many seconds",
297            input_hint: "The span it counts within. 10 catches a burst; 60 catches a sustained flood.",
298            rule: rule(
299                "rate",
300                Match::Rate { per_secs: 10 },
301                tiers(vec![], vec![rung(6, Severity::Major, 50), rung(12, Severity::Severe, 85)]),
302            ),
303        },
304        RuleKind {
305            id: "cohort",
306            label: "Many accounts, one line",
307            description: "Separate accounts posting the same line at the same time. The shape of a raid.",
308            input: "none",
309            input_label: "",
310            input_hint: "",
311            rule: single(
312                "cohort",
313                Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
314                Severity::Severe,
315                85,
316                None,
317            ),
318        },
319    ]
320}
321
322pub fn all() -> Vec<Preset> {
323    vec![
324        Preset {
325            id: super::harness::DEFAULTS_POLICY_ID,
326            name: "Vector's Defaults",
327            description: "Raid detection, running here already. Open it to read every rule, or save your own version.",
328            example: "a swarm of fresh accounts posting one line",
329            caveat: "A Built-In Anti-Raid Policy: detects raids proactively and alerts you with a list of suspects for quick handling.",
330            dials: vec![
331                Dial { key: "summary", label: "What these rules do", kind: "summary",
332                       hint: "Every rule that runs here, in order." },
333                Dial { key: "strictness", label: "Sensitivity", kind: "strictness",
334                       hint: "How much it takes to trip a rule, and how confident the result is." },
335            ],
336            policy: super::harness::default_policy(),
337        },
338        Preset {
339            id: "scam_links",
340            name: "Scam Links",
341            description: "Blocks link shorteners and redirectors that scams hide behind.",
342            example: "claim your airdrop at bit.ly/…",
343            caveat: "Only catches the shorteners on the bundled list, plus any domain you add.",
344            dials: vec![
345                Dial { key: "domains", label: "Also block these domains", kind: "domainlist",
346                       hint: "One per line. The bundled shortener list is always included." },
347                Dial { key: "allow", label: "Never flag these domains", kind: "domainlist",
348                       hint: "Your own links, docs, anything you trust." },
349            ],
350            policy: base(
351                "Scam Links",
352                vec![rule(
353                    "shorteners",
354                    Match::Link { patterns: SHORTENERS.iter().map(|s| s.to_string()).collect() },
355                    tiers(vec![rung(1, Severity::Severe, 70)], vec![rung(3, Severity::Severe, 90)]),
356                )],
357                168,
358            ),
359        },
360        Preset {
361            id: "no_spam",
362            name: "No Spam",
363            description: "The same message over and over, from one account or many.",
364            example: "the same pitch, twelve times",
365            caveat: "Counts shapes, not words: changing case, punctuation or digits does not help a spammer.",
366            dials: vec![Dial {
367                key: "strictness",
368                label: "Sensitivity",
369                kind: "strictness",
370                hint: "How much it takes to trip a rule, and how confident the result is.",
371            }],
372            policy: base(
373                "No Spam",
374                vec![
375                    rule(
376                        "repeat",
377                        Match::Repeat { normalize: Normalize::Skeleton, within_secs: Some(super::harness::REPEAT_BURST_SECS) },
378                        tiers(vec![], vec![rung(4, Severity::Major, 50), rung(8, Severity::Severe, 85)]),
379                    ),
380                    single(
381                        "cohort",
382                        Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
383                        Severity::Severe,
384                        85,
385                        None,
386                    ),
387                ],
388                168,
389            ),
390        },
391        Preset {
392            id: "word_filter",
393            name: "Word Filter",
394            description: "Any list of words you choose, with per-channel exceptions.",
395            example: "spoilers, slurs, or a scam phrase",
396            caveat: "Whole words by default, so filtering \"art\" leaves \"start\" alone.",
397            dials: vec![
398                Dial { key: "words", label: "Words to catch", kind: "wordlist",
399                       hint: "One per line. Use *word* to match inside longer words." },
400                Dial { key: "exempt_channels", label: "Allowed in these channels", kind: "channels",
401                       hint: "The filter ignores these channels entirely." },
402            ],
403            policy: base(
404                "Word Filter",
405                vec![rule(
406                    "words",
407                    Match::Keyword { patterns: vec![], normalize: Normalize::Fold },
408                    tiers(vec![rung(1, Severity::Minor, 10)], vec![rung(10, Severity::Major, 45)]),
409                )],
410                168,
411            ),
412        },
413        Preset {
414            id: "mass_tagging",
415            name: "Mass Tagging",
416            description: "One message that names a crowd, rather than a person.",
417            example: "a post tagging twelve people at once",
418            caveat: "Counts distinct people per message: pinging one person twenty times reads as one, because it is.",
419            dials: vec![
420                Dial { key: "strictness", label: "Sensitivity", kind: "strictness",
421                       hint: "How much it takes to trip a rule, and how confident the result is." },
422                Dial { key: "exempt_channels", label: "Allowed in these channels", kind: "channels",
423                       hint: "Announcement channels where tagging everyone is the point." },
424            ],
425            policy: base(
426                "Mass Tagging",
427                vec![rule(
428                    "mentions",
429                    Match::Mentions {},
430                    tiers(vec![rung(6, Severity::Major, 50), rung(12, Severity::Severe, 85)], vec![]),
431                )],
432                168,
433            ),
434        },
435        Preset {
436            id: "rate_limit",
437            name: "Rate Limit",
438            description: "Too many messages too quickly from one account, whatever they say.",
439            example: "twelve messages in ten seconds",
440            caveat: "Counts messages, not content: a fast typer in a lively channel can trip it, so preview before enabling.",
441            dials: vec![
442                Dial { key: "per_secs", label: "Over how many seconds", kind: "seconds",
443                       hint: "The span it counts within. 10 catches a burst; 60 catches a sustained flood." },
444                Dial { key: "strictness", label: "Sensitivity", kind: "strictness",
445                       hint: "How much it takes to trip a rule, and how confident the result is." },
446            ],
447            policy: base(
448                "Rate Limit",
449                vec![rule(
450                    "rate",
451                    Match::Rate { per_secs: 10 },
452                    tiers(vec![], vec![rung(6, Severity::Major, 50), rung(12, Severity::Severe, 85)]),
453                )],
454                168,
455            ),
456        },
457        Preset {
458            id: "blank",
459            name: "Start from scratch",
460            description: "An empty policy. Name it, add the rules you want, preview before anything runs.",
461            example: "whatever this community actually needs",
462            caveat: "A policy with no rules catches nothing, and the preview will tell you so.",
463            dials: vec![
464                Dial { key: "name", label: "Policy name", kind: "text",
465                       hint: "What you will see in the list." },
466                Dial { key: "rules", label: "Rules", kind: "rules",
467                       hint: "Add as many as you like. Any one of them can convict on its own." },
468                Dial { key: "exempt_channels", label: "Allowed in these channels", kind: "channels",
469                       hint: "These channels are ignored entirely." },
470            ],
471            policy: base("New policy", vec![], 168),
472        },
473    ]
474}
475
476pub fn by_id(id: &str) -> Option<Preset> {
477    all().into_iter().find(|p| p.id == id)
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    /// A preset that cannot validate is a shipped outage: the designer would
485    /// offer it, the admin would pick it, and the save would fail.
486    #[test]
487    fn every_preset_validates() {
488        for p in all() {
489            assert!(p.policy.validate().is_ok(), "preset {} must validate: {:?}", p.id, p.policy.validate());
490        }
491    }
492
493    /// Weak signals must never speak alone — the lesson from convicting 147 of
494    /// 155 members on "has barely posted".
495    #[test]
496    fn no_preset_lets_a_weak_signal_convict_alone() {
497        for p in all() {
498            for r in &p.policy.rules {
499                // A join flood may stand alone (it is a raid path in its own
500                // right, kept below the acting bands); "joined recently" and
501                // "barely posted" describe most of a healthy community and never
502                // may.
503                let describes_the_innocent =
504                    matches!(r.matcher, Match::TenureLt { .. } | Match::MessagesLte { .. });
505                if describes_the_innocent {
506                    assert!(r.armed_by.is_some(), "preset {} rule {} must never speak alone", p.id, r.id);
507                }
508            }
509        }
510    }
511
512    /// Every preset says what it cannot do. A template that only advertises its
513    /// powers reads as a promise it may not keep.
514    #[test]
515    fn every_preset_states_its_limits() {
516        for p in all() {
517            assert!(!p.caveat.is_empty(), "preset {} needs a caveat", p.id);
518            assert!(!p.example.is_empty(), "preset {} needs a concrete example", p.id);
519            assert!(!p.dials.is_empty(), "preset {} needs at least one dial", p.id);
520        }
521    }
522
523    /// A description that skips a rule is worse than none: it reads as a
524    /// complete account of what runs.
525    #[test]
526    fn every_rule_of_every_preset_is_described() {
527        for p in all() {
528            let described = describe(&p.policy);
529            assert_eq!(described.len(), p.policy.rules.len(), "{} left rules undescribed", p.id);
530            for (d, r) in described.iter().zip(p.policy.rules.iter()) {
531                assert!(!d.label.is_empty() && !d.detail.is_empty(), "{} rule {} has no words", p.id, r.id);
532                assert_eq!(d.armed, r.armed_by.is_some(), "{} rule {} misreports arming", p.id, r.id);
533            }
534        }
535    }
536
537    /// Aggravators only mean anything behind a conviction, so a summary that
538    /// does not mark them promises cover the engine will not give.
539    #[test]
540    fn the_shipped_defaults_mark_their_aggravators() {
541        let d = describe(&super::super::harness::default_policy());
542        assert!(d.iter().any(|x| x.armed), "the defaults carry aggravators and none was marked");
543        assert!(d.iter().any(|x| !x.armed), "every rule marked armed would leave nothing that can convict");
544    }
545}