glassframe-protocol 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,50 @@ lives in `src/core/VersionInfo.js` (`VersionInfo.info()` /
|
|
|
14
14
|
`VersionInfo.banner()`), so code can read the current version without
|
|
15
15
|
parsing this file.
|
|
16
16
|
|
|
17
|
+
## [2.2.0] - 2026-08-05
|
|
18
|
+
|
|
19
|
+
Fixes a real bug in message deletion and adds tenure-aware leniency for
|
|
20
|
+
spam detection. No breaking changes.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Protected/trusted members' messages were being deleted anyway.**
|
|
25
|
+
`BasicSecurityLayer` computed trust and used it to gate the @everyone
|
|
26
|
+
check, but every *other* trigger (message rate, duplicate flood, mention
|
|
27
|
+
spam, scam/phishing/raid language, links) deleted the message
|
|
28
|
+
unconditionally, before `PunishmentEngine` ever got a chance to decide
|
|
29
|
+
the sender shouldn't be punished. A real admin posting several messages
|
|
30
|
+
quickly during a busy conversation, or pasting a couple of links, had
|
|
31
|
+
their message removed even though they were never going to be actioned.
|
|
32
|
+
Deletion is now gated the same way punishment is: never for a HIGH/
|
|
33
|
+
PROTECTED-trust sender.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Expanded `roleAnalysis.protectedNames`**: added `co-owner`, `founder`,
|
|
38
|
+
`management`, `manager`, `support`, `helper`, `developer` alongside the
|
|
39
|
+
existing `owner`/`admin`/`administrator`/`moderator`/`mod`/`staff`/
|
|
40
|
+
`discord staff`/`security`.
|
|
41
|
+
- **Established-member leniency** (`RoleAnalyzer.isEstablishedMember()`,
|
|
42
|
+
`roleAnalysis.maturity`, `basicSecurity.establishedMemberLeniency`) - a
|
|
43
|
+
member with an old-enough account (default 6 months) and long-enough
|
|
44
|
+
tenure in the server (default 1 month) needs a much bigger burst before
|
|
45
|
+
a pure rate/duplicate-flood signal fires at all (2.5x by default), that
|
|
46
|
+
signal counts for less when it does (0.3x weight by default), and their
|
|
47
|
+
messages are never deleted for tripping *only* a rate/volume signal.
|
|
48
|
+
This is a separate axis from role-based trust above - an ordinary member
|
|
49
|
+
with no special role can be "established."
|
|
50
|
+
|
|
51
|
+
Deliberately scoped narrow: this only ever softens detection of *sending
|
|
52
|
+
frequently*. Scam/phishing/raid-recruitment language, suspicious links,
|
|
53
|
+
and non-trusted mass pings stay at full sensitivity and still get
|
|
54
|
+
deleted and actioned regardless of how long the account has existed -
|
|
55
|
+
tenure says nothing about whether a specific message is dangerous, and a
|
|
56
|
+
long-standing account can still genuinely raid, spam, or get
|
|
57
|
+
compromised. Modeled on how invisible CAPTCHA works: judge from signals
|
|
58
|
+
already available rather than one blunt threshold, without ever handing
|
|
59
|
+
out blanket immunity.
|
|
60
|
+
|
|
17
61
|
## [2.1.0] - 2026-08-01
|
|
18
62
|
|
|
19
63
|
Additive - nothing from 2.0.0's API changes. New detections, a plugin
|
package/dist/config.js
CHANGED
|
@@ -11,8 +11,9 @@ module.exports = {
|
|
|
11
11
|
roleAnalysis: {
|
|
12
12
|
// Role-name fragments (lowercased, substring match) that imply real authority.
|
|
13
13
|
protectedNames: [
|
|
14
|
-
"owner", "
|
|
15
|
-
"staff", "discord staff", "security"
|
|
14
|
+
"owner", "co-owner", "founder", "admin", "administrator",
|
|
15
|
+
"moderator", "mod", "staff", "discord staff", "security",
|
|
16
|
+
"management", "manager", "support", "helper", "developer"
|
|
16
17
|
],
|
|
17
18
|
// Permission names (PermissionsBitField.Flags keys) treated as "dangerous"
|
|
18
19
|
// when granted to a role - watched by AntiNuke's grant watchdog.
|
|
@@ -23,7 +24,16 @@ module.exports = {
|
|
|
23
24
|
// Permission names that mark a member as genuinely trusted staff.
|
|
24
25
|
trustPermissions: ["Administrator", "ManageGuild", "ManageRoles", "BanMembers", "KickMembers"],
|
|
25
26
|
// A role younger than this that already holds a dangerous permission is extra suspicious.
|
|
26
|
-
newRoleGraceMs: 1000 * 60 * 10
|
|
27
|
+
newRoleGraceMs: 1000 * 60 * 10,
|
|
28
|
+
// A DIFFERENT axis from role-based trust above: how long an account
|
|
29
|
+
// and membership has simply existed, with no relation to roles or
|
|
30
|
+
// permissions. Used only to soften pure rate/volume spam detection for
|
|
31
|
+
// members who are just very active - see basicSecurity.establishedMemberLeniency
|
|
32
|
+
// and docs/PROTOCOL_LAYERS.md ("Established-member leniency").
|
|
33
|
+
maturity: {
|
|
34
|
+
minAccountAgeMs: 1000 * 60 * 60 * 24 * 180, // 6 months
|
|
35
|
+
minServerTenureMs: 1000 * 60 * 60 * 24 * 30 // 1 month in this server
|
|
36
|
+
}
|
|
27
37
|
},
|
|
28
38
|
|
|
29
39
|
threatEngine: {
|
|
@@ -48,7 +58,18 @@ module.exports = {
|
|
|
48
58
|
// Seed domains for PhishingDatabase (src/security/PhishingDatabase.js).
|
|
49
59
|
// Empty by default - populate with what you actually see via
|
|
50
60
|
// `!gf phishing add <domain>` rather than shipping unverified blocklists.
|
|
51
|
-
linkGuard: { seedDomains: [] }
|
|
61
|
+
linkGuard: { seedDomains: [] },
|
|
62
|
+
// See roleAnalysis.maturity. Applies ONLY to pure rate/volume signals
|
|
63
|
+
// (message rate, duplicate flood) for established members - content
|
|
64
|
+
// signals (scam/phishing/raid language, links, non-trusted mass pings)
|
|
65
|
+
// stay at full strength no matter how old or long-tenured the sender
|
|
66
|
+
// is, since tenure says nothing about whether a given message is
|
|
67
|
+
// dangerous. Set both multipliers to 1 to disable this and treat
|
|
68
|
+
// every member identically.
|
|
69
|
+
establishedMemberLeniency: {
|
|
70
|
+
thresholdMultiplier: 2.5, // established members need this many times the normal rate before a signal fires at all
|
|
71
|
+
weightMultiplier: 0.3 // and the signal counts for less when it does
|
|
72
|
+
}
|
|
52
73
|
},
|
|
53
74
|
|
|
54
75
|
antiRaid: {
|
|
@@ -45,21 +45,35 @@ class BasicSecurityLayer extends Layer {
|
|
|
45
45
|
const linkFlag = this._checkLinks(message.content || "");
|
|
46
46
|
const hasInvite = this._checkInviteLink(message.content || "");
|
|
47
47
|
const trust = this.frame.roleAnalyzer.trustLevel(message.member);
|
|
48
|
+
const established = this.frame.roleAnalyzer.isEstablishedMember(message.member);
|
|
49
|
+
const leniency = cfg.establishedMemberLeniency;
|
|
48
50
|
|
|
49
51
|
const reasons = [];
|
|
50
52
|
let weight = 0;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
let contentSignalFired = false; // anything other than pure rate/volume - see the deletion gate below
|
|
54
|
+
|
|
55
|
+
// Rate/volume signals: an established member needs a much bigger burst
|
|
56
|
+
// before this counts as a signal at all, and it counts for less when it
|
|
57
|
+
// does - see roleAnalysis.maturity for the reasoning. A brand-new or
|
|
58
|
+
// short-tenured member gets the normal, stricter threshold.
|
|
59
|
+
const messageThreshold = established ? Math.ceil(cfg.spam.messageThreshold * leniency.thresholdMultiplier) : cfg.spam.messageThreshold;
|
|
60
|
+
if (buf.timestamps.length >= messageThreshold) {
|
|
61
|
+
reasons.push(`message rate ${buf.timestamps.length}/${cfg.spam.windowMs / 1000}s${established ? " (established member, reduced sensitivity)" : ""}`);
|
|
62
|
+
weight += established ? Math.round(20 * leniency.weightMultiplier) : 20;
|
|
55
63
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
const duplicateThreshold = established ? Math.ceil(cfg.spam.duplicateThreshold * leniency.thresholdMultiplier) : cfg.spam.duplicateThreshold;
|
|
65
|
+
if (buf.dupCount >= duplicateThreshold) {
|
|
66
|
+
reasons.push(`duplicate flood x${buf.dupCount}${established ? " (established member, reduced sensitivity)" : ""}`);
|
|
67
|
+
weight += established ? Math.round(20 * leniency.weightMultiplier) : 20;
|
|
59
68
|
}
|
|
69
|
+
|
|
70
|
+
// Everything below is about message CONTENT, not sending frequency -
|
|
71
|
+
// full strength regardless of tenure, since how long an account has
|
|
72
|
+
// existed says nothing about whether THIS message is dangerous.
|
|
60
73
|
if (mentionCount >= cfg.mentionSpam.maxMentionsPerMessage) {
|
|
61
74
|
reasons.push(`mention spam (${mentionCount})`);
|
|
62
75
|
weight += 25;
|
|
76
|
+
contentSignalFired = true;
|
|
63
77
|
}
|
|
64
78
|
// A single @everyone/@here reaches every member at once - much higher
|
|
65
79
|
// impact than several individual mentions, so it's weighted heavier and
|
|
@@ -68,18 +82,22 @@ class BasicSecurityLayer extends Layer {
|
|
|
68
82
|
if (message.mentions.everyone && trust !== "HIGH" && trust !== "PROTECTED") {
|
|
69
83
|
reasons.push("@everyone/@here mention from a non-trusted member");
|
|
70
84
|
weight += 50;
|
|
85
|
+
contentSignalFired = true;
|
|
71
86
|
}
|
|
72
87
|
if (classification.scam >= cfg.nlp.scamScoreThreshold) {
|
|
73
88
|
reasons.push(`scam language (${classification.scam.toFixed(2)})`);
|
|
74
89
|
weight += 35;
|
|
90
|
+
contentSignalFired = true;
|
|
75
91
|
}
|
|
76
92
|
if (classification.phishing >= cfg.nlp.phishingScoreThreshold) {
|
|
77
93
|
reasons.push(`phishing language (${classification.phishing.toFixed(2)})`);
|
|
78
94
|
weight += 35;
|
|
95
|
+
contentSignalFired = true;
|
|
79
96
|
}
|
|
80
97
|
if (classification.raidCallout >= cfg.nlp.raidCalloutThreshold) {
|
|
81
98
|
reasons.push(`raid-recruitment language (${classification.raidCallout.toFixed(2)})`);
|
|
82
99
|
weight += 35;
|
|
100
|
+
contentSignalFired = true;
|
|
83
101
|
}
|
|
84
102
|
// The specific pattern in a lot of real raid-tool spam: an invite link
|
|
85
103
|
// to another server, paired with language recruiting people to raid
|
|
@@ -90,10 +108,12 @@ class BasicSecurityLayer extends Layer {
|
|
|
90
108
|
if (hasInvite && classification.raidCallout >= cfg.nlp.raidCalloutThreshold && trust !== "HIGH" && trust !== "PROTECTED") {
|
|
91
109
|
reasons.push("Discord invite link paired with raid-recruitment language");
|
|
92
110
|
weight += 50;
|
|
111
|
+
contentSignalFired = true;
|
|
93
112
|
}
|
|
94
113
|
if (linkFlag) {
|
|
95
114
|
reasons.push(linkFlag);
|
|
96
115
|
weight += 30;
|
|
116
|
+
contentSignalFired = true;
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
if (!reasons.length) {
|
|
@@ -101,10 +121,22 @@ class BasicSecurityLayer extends Layer {
|
|
|
101
121
|
return;
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
124
|
+
// Never delete a recognized admin/staff member's message over a
|
|
125
|
+
// heuristic - they'll be flagged for review if warranted (see
|
|
126
|
+
// PunishmentEngine's trust gating), but the message itself stays up.
|
|
127
|
+
// Same treatment for an established member who ONLY tripped a rate/
|
|
128
|
+
// volume signal (just a very active regular, not a raider) - but if a
|
|
129
|
+
// CONTENT signal fired too, the message comes down regardless of
|
|
130
|
+
// tenure, since a long-standing account can still post something
|
|
131
|
+
// genuinely dangerous.
|
|
132
|
+
const isProtectedTrust = trust === "HIGH" || trust === "PROTECTED";
|
|
133
|
+
const softenedForTenure = established && !contentSignalFired;
|
|
134
|
+
if (!isProtectedTrust && !softenedForTenure) {
|
|
135
|
+
try {
|
|
136
|
+
if (message.deletable) await message.delete();
|
|
137
|
+
} catch {
|
|
138
|
+
/* best effort - still report the signal even if delete fails */
|
|
139
|
+
}
|
|
108
140
|
}
|
|
109
141
|
|
|
110
142
|
await this.frame.punishmentEngine.report({
|
|
@@ -39,6 +39,32 @@ class RoleAnalyzer {
|
|
|
39
39
|
return "LOW";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A DIFFERENT question from trustLevel() above: not "does this member
|
|
44
|
+
* hold real authority," but "has this account and this membership simply
|
|
45
|
+
* existed long enough to not look like a fresh raid/alt account." No
|
|
46
|
+
* relation to roles or permissions - a totally ordinary member with no
|
|
47
|
+
* special role can be "established," and a MEDIUM-trust role holder with
|
|
48
|
+
* a week-old account isn't.
|
|
49
|
+
*
|
|
50
|
+
* This exists to soften pure rate/volume spam detection (see
|
|
51
|
+
* BasicSecurityLayer) for members who are simply very active, the way
|
|
52
|
+
* modern invisible CAPTCHA judges from signals already available instead
|
|
53
|
+
* of a one-size-fits-all challenge - it is deliberately NOT used to
|
|
54
|
+
* soften content-based detection (scam/phishing/raid language, links),
|
|
55
|
+
* since account age says nothing about whether a specific message is
|
|
56
|
+
* dangerous. An established account can still raid or spam; this only
|
|
57
|
+
* ever reduces false positives on "sends a lot of messages," never
|
|
58
|
+
* blanket-exempts anyone from everything else.
|
|
59
|
+
*/
|
|
60
|
+
isEstablishedMember(member) {
|
|
61
|
+
if (!member || !member.user) return false;
|
|
62
|
+
const cfg = this.config.maturity;
|
|
63
|
+
const accountAge = Date.now() - member.user.createdTimestamp;
|
|
64
|
+
const serverTenure = member.joinedTimestamp ? Date.now() - member.joinedTimestamp : 0;
|
|
65
|
+
return accountAge >= cfg.minAccountAgeMs && serverTenure >= cfg.minServerTenureMs;
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
/**
|
|
43
69
|
* Scans a guild's current roles for name/permission mismatches: a role
|
|
44
70
|
* whose name signals authority ("admin", "staff", ...) but carries no real
|
package/docs/PROTOCOL_LAYERS.md
CHANGED
|
@@ -132,3 +132,27 @@ frame.registerAction("addMutedRole", async (member) => {
|
|
|
132
132
|
```js
|
|
133
133
|
config: { punishment: { ladder: { medium: "addMutedRole" } } }
|
|
134
134
|
```
|
|
135
|
+
|
|
136
|
+
## Established-member leniency
|
|
137
|
+
|
|
138
|
+
A separate axis from role-based trust, for a question role trust can't
|
|
139
|
+
answer: not "does this member have authority," but "has this account and
|
|
140
|
+
this membership simply existed long enough to not look like a fresh raid
|
|
141
|
+
account." `RoleAnalyzer.isEstablishedMember(member)` checks account age and
|
|
142
|
+
server tenure against `roleAnalysis.maturity` (defaults: 6 months, 1
|
|
143
|
+
month). A totally ordinary member with no special role can be
|
|
144
|
+
"established"; a MEDIUM-trust role holder with a week-old account isn't.
|
|
145
|
+
|
|
146
|
+
`BasicSecurityLayer` is the only place this is used, and only for the two
|
|
147
|
+
pure rate/volume checks (message rate, duplicate flood) -
|
|
148
|
+
`basicSecurity.establishedMemberLeniency` raises the threshold before
|
|
149
|
+
either fires at all and reduces the weight when it does, and neither
|
|
150
|
+
deletes an established member's message on its own. Every content-based
|
|
151
|
+
check (scam/phishing/raid-recruitment language, links, non-trusted mass
|
|
152
|
+
pings) is completely unaffected by tenure - full threshold, full weight,
|
|
153
|
+
message still deleted - since how long an account has existed says nothing
|
|
154
|
+
about whether a specific message is dangerous. This is deliberate: the
|
|
155
|
+
point is recognizing "this is just a very active regular," not granting
|
|
156
|
+
immunity. An established account can still raid, spam, or get compromised,
|
|
157
|
+
and will still be caught the moment it does anything content-wise, not
|
|
158
|
+
just volume-wise.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glassframe-protocol",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "A layered, self-contained Discord security engine - AntiRaid, AntiNuke, Basic Security, and optional Groq-powered AI Moderation sharing one threat-scoring and punishment pipeline. Prefix commands only, Components V2 output, no slash commands.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|