glassframe-protocol 1.2.1 → 2.1.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.
@@ -1,16 +1,25 @@
1
1
  "use strict";
2
2
 
3
- const { buildPanel } = require("../ui/ControlPanel");
3
+ const { buildPanel, buildMetricsMessage, buildEnginePage } = require("../ui/ControlPanel");
4
4
  const { buildLogMessage } = require("../logging/ComponentsV2");
5
5
 
6
- const LAYER_NAMES = ["antiRaid", "antiNuke", "basicSecurity", "aiModeration"];
7
- const COMMANDS = ["panel", "status", "help", "whitelist", "scan", "metrics", "phishing"];
6
+ const COMMANDS = ["panel", "status", "help", "whitelist", "scan", "metrics", "phishing", "prefix", "engine"];
7
+ const OWNER_ONLY_COMMANDS = new Set(["engine"]);
8
+
9
+ const LAYER_REASONING = {
10
+ basicSecurity: "watches every message for spam, scam/phishing language, raid-recruitment language, and bad links.",
11
+ antiRaid: "watches joins for rate spikes, alt-account patterns, and account-creation clusters.",
12
+ antiNuke: "watches the audit log for destructive bursts, dangerous permission grants, and channel lockouts.",
13
+ aiModeration: "sends only ambiguous messages (not clean, not clearly bad) to Groq for a second opinion."
14
+ };
8
15
 
9
16
  /**
10
17
  * GlassFrame Protocol registers no slash commands, by design (see
11
18
  * docs/COMMANDS.md). Every command here is a plain prefix command gated on
12
19
  * Manage Server permission, with a short per-user cooldown so a doubled
13
- * keypress can't fire the same command twice in a row.
20
+ * keypress can't fire the same command twice in a row. Each server can set
21
+ * its own prefix (`!gf prefix set`); the default from config.js always
22
+ * still works too, as a fallback.
14
23
  */
15
24
  class PrefixRouter {
16
25
  constructor(frame) {
@@ -32,16 +41,25 @@ class PrefixRouter {
32
41
  return false;
33
42
  }
34
43
 
44
+ /** Tries this server's custom prefix (if set) and the default, longer one first, so an overlapping pair resolves sensibly. */
45
+ _matchPrefix(message) {
46
+ const custom = this.frame.guildPrefixes.get(message.guild.id);
47
+ const candidates = [custom, this.frame.config.prefix].filter(Boolean).sort((a, b) => b.length - a.length);
48
+ return candidates.find((p) => message.content.toLowerCase().startsWith(p.toLowerCase())) || null;
49
+ }
50
+
35
51
  async _handle(message) {
36
52
  if (!message.guild || message.author.bot) return;
37
- const prefix = this.frame.config.prefix;
38
- if (!message.content.toLowerCase().startsWith(prefix.toLowerCase())) return;
53
+ const prefix = this._matchPrefix(message);
54
+ if (!prefix) return;
39
55
 
40
56
  const args = message.content.slice(prefix.length).trim().split(/\s+/);
41
57
  const command = (args.shift() || "").toLowerCase();
42
58
  if (!COMMANDS.includes(command)) return;
43
59
 
44
- if (!this.frame.isAuthorized(message.member)) {
60
+ if (OWNER_ONLY_COMMANDS.has(command)) {
61
+ if (!this.frame.isOwner(message.author.id)) return; // silent - this command doesn't exist as far as non-owners can tell
62
+ } else if (!this.frame.isAuthorized(message.member)) {
45
63
  await message.reply({ content: "You need Manage Server permission to use GlassFrame Protocol commands." });
46
64
  return;
47
65
  }
@@ -55,6 +73,8 @@ class PrefixRouter {
55
73
  if (command === "scan") return this._scan(message);
56
74
  if (command === "metrics") return this._metrics(message);
57
75
  if (command === "phishing") return this._phishing(message, args);
76
+ if (command === "prefix") return this._prefix(message, args);
77
+ if (command === "engine") return this._engine(message, args);
58
78
  }
59
79
 
60
80
  async _panel(message) {
@@ -63,46 +83,66 @@ class PrefixRouter {
63
83
  this.frame.panelMessages.set(sent.id, message.guild.id);
64
84
  }
65
85
 
86
+ /** Each layer's line explains what it actually watches for, not just on/off - the same reasoning-first approach as metrics. */
66
87
  async _status(message) {
67
88
  const status = this.frame.getStatus(message.guild.id);
68
- const fields = LAYER_NAMES.map((l) => ({ name: l, value: status[l] ? "ACTIVE" : "INACTIVE" }));
89
+ const fields = Object.keys(this.frame.layers).map((name) => ({
90
+ name: `${status[name] ? "ACTIVE" : "INACTIVE"} - ${name}`,
91
+ value: LAYER_REASONING[name] || "a custom layer registered via frame.registerLayer()."
92
+ }));
69
93
  const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Status", fields });
70
94
  await message.channel.send(payload);
71
95
  }
72
96
 
73
97
  async _help(message) {
74
- const prefix = this.frame.config.prefix;
75
- const payload = buildLogMessage({
76
- level: "info",
77
- title: "GlassFrame Protocol - Commands",
78
- description: [
79
- `${prefix}panel - open the 5-button control panel`,
80
- `${prefix}status - show which layers are active`,
81
- `${prefix}scan - check current roles for name/permission mismatches`,
82
- `${prefix}metrics - show performance and cache health`,
83
- `${prefix}phishing add|remove|list <domain> - manage the link blocklist`,
84
- `${prefix}whitelist add <userId> - exempt a user from all punitive action`,
85
- `${prefix}whitelist remove <userId> - remove an exemption`,
86
- `${prefix}help - this message`
87
- ].join("\n")
88
- });
98
+ const prefix = this.frame.getPrefix(message.guild.id);
99
+ const sections = [
100
+ {
101
+ name: "Getting started",
102
+ value: [
103
+ `${prefix}panel - the 5-button control panel. Nothing runs until a layer is armed here (or via autoStart in code).`,
104
+ `${prefix}status - which layers are on right now, and what each one actually watches for.`
105
+ ].join("\n")
106
+ },
107
+ {
108
+ name: "Security tools",
109
+ value: [
110
+ `${prefix}scan - checks current roles for name/permission mismatches (impersonation bait, quiet privilege grants).`,
111
+ `${prefix}phishing add|remove|list <domain> - manage the link blocklist Basic Security checks messages against.`,
112
+ `${prefix}whitelist add|remove <userId> - exempt someone from punitive action, in this server only.`
113
+ ].join("\n")
114
+ },
115
+ {
116
+ name: "Diagnostics",
117
+ value: `${prefix}metrics - this server's numbers (open cases, flagged members, whitelist size); one button on the message switches to bot-wide totals across every server.`
118
+ },
119
+ {
120
+ name: "Customization",
121
+ value: [
122
+ `${prefix}prefix set <newPrefix> - change this server's command prefix.`,
123
+ `${prefix}prefix reset - go back to the default (\`${this.frame.config.prefix}\`).`
124
+ ].join("\n")
125
+ }
126
+ ];
127
+ const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Help", fields: sections });
89
128
  await message.channel.send(payload);
90
129
  }
91
130
 
92
131
  async _whitelist(message, args) {
132
+ const prefix = this.frame.getPrefix(message.guild.id);
93
133
  const [sub, id] = args;
94
134
  if (!sub || !id) {
95
- await message.reply({ content: `Usage: ${this.frame.config.prefix}whitelist <add|remove> <userId>` });
135
+ await message.reply({ content: `Usage: ${prefix}whitelist <add|remove> <userId>` });
96
136
  return;
97
137
  }
98
138
 
99
139
  const action = sub.toLowerCase();
100
- if (action === "add") this.frame.whitelist.add(id);
101
- else if (action === "remove") this.frame.whitelist.delete(id);
140
+ if (action === "add") this.frame.addToWhitelist(message.guild.id, id);
141
+ else if (action === "remove") this.frame.removeFromWhitelist(message.guild.id, id);
102
142
  else return;
103
143
 
104
144
  await message.reply({
105
- content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list.`
145
+ content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list for this server.`
106
146
  });
107
147
  }
108
148
 
@@ -121,32 +161,13 @@ class PrefixRouter {
121
161
  }
122
162
 
123
163
  async _metrics(message) {
124
- const m = this.frame.getMetrics();
125
- const fmtCache = (c) => `size ${c.size} | hits ${c.hits} | misses ${c.misses} | evictions ${c.evictions}`;
126
- const fmtQueue = (q) => `active ${q.active}/${q.concurrency} | pending ${q.pending} | completed ${q.completed} | failed ${q.failed}`;
127
- const fmtLatency = Object.entries(m.performance.avgLatencyMs)
128
- .map(([name, ms]) => `${name}: ${ms}ms avg`)
129
- .join("\n") || "no samples yet";
130
-
131
- const payload = buildLogMessage({
132
- level: "info",
133
- title: "GlassFrame Protocol - Performance",
134
- description: `Uptime: ${Math.round(m.performance.uptimeMs / 60000)} min`,
135
- fields: [
136
- { name: "Action queue", value: fmtQueue(m.queues.actions) },
137
- { name: "AI queue", value: fmtQueue(m.queues.ai) },
138
- { name: "Threat score cache", value: fmtCache(m.caches.threatScores) },
139
- { name: "Open case cache", value: fmtCache(m.caches.openCases) },
140
- { name: "Groq verdict cache", value: fmtCache(m.caches.groqVerdicts) },
141
- { name: "Average latency", value: fmtLatency }
142
- ]
143
- });
164
+ const payload = buildMetricsMessage(this.frame, message.guild.id, "guild");
144
165
  await message.channel.send(payload);
145
166
  }
146
167
 
147
168
  async _phishing(message, args) {
169
+ const prefix = this.frame.getPrefix(message.guild.id);
148
170
  const [sub, domain] = args;
149
- const prefix = this.frame.config.prefix;
150
171
 
151
172
  if (!sub || sub.toLowerCase() === "list") {
152
173
  const domains = this.frame.phishingDatabase.list();
@@ -172,6 +193,60 @@ class PrefixRouter {
172
193
  await message.reply({ content: `Usage: ${prefix}phishing <add|remove|list> <domain>` });
173
194
  }
174
195
  }
196
+
197
+ async _prefix(message, args) {
198
+ const [sub, newPrefix] = args;
199
+ const current = this.frame.getPrefix(message.guild.id);
200
+
201
+ if (!sub) {
202
+ await message.reply({ content: `This server's prefix is currently \`${current}\`. Usage: ${current}prefix set <newPrefix> | reset` });
203
+ return;
204
+ }
205
+
206
+ const action = sub.toLowerCase();
207
+ if (action === "reset") {
208
+ this.frame.guildPrefixes.delete(message.guild.id);
209
+ this.frame._persistGuildState(message.guild.id);
210
+ await message.reply({ content: `Prefix reset to the default: \`${this.frame.config.prefix}\`` });
211
+ return;
212
+ }
213
+
214
+ if (action === "set") {
215
+ if (!newPrefix) {
216
+ await message.reply({ content: `Usage: ${current}prefix set <newPrefix>` });
217
+ return;
218
+ }
219
+ this.frame.setPrefix(message.guild.id, newPrefix);
220
+ await message.reply({
221
+ content: `This server's prefix is now \`${newPrefix}\`. The default \`${this.frame.config.prefix}\` still works too, as a fallback.`
222
+ });
223
+ return;
224
+ }
225
+
226
+ await message.reply({ content: `Usage: ${current}prefix set <newPrefix> | reset` });
227
+ }
228
+
229
+ /**
230
+ * Owner-only, bot-wide dashboard - see frame.getEngineReport(). Not
231
+ * listed in !gf help on purpose. If config.engine.password is set, it's
232
+ * required as `!gf engine <password>`; the command message is deleted
233
+ * either way (right or wrong) so the password never sits visible in
234
+ * channel history. Wrong-password attempts count toward a lockout - see
235
+ * GlassFrame.verifyEnginePassword().
236
+ */
237
+ async _engine(message, args) {
238
+ const passwordOk = this.frame.verifyEnginePassword(message.author.id, args.join(" "));
239
+ try {
240
+ if (message.deletable) await message.delete();
241
+ } catch {
242
+ /* best effort - if the bot can't delete here, that's a permissions gap to fix, not a reason to fail open */
243
+ }
244
+ if (!passwordOk) return;
245
+
246
+ const report = this.frame.getEngineReport();
247
+ const payload = buildEnginePage(report, "overview");
248
+ await message.channel.send(payload);
249
+ }
175
250
  }
176
251
 
177
252
  module.exports = PrefixRouter;
@@ -84,6 +84,16 @@ class ProtocolCache {
84
84
  return { ...this.stats, size: this.store.size, name: this.name };
85
85
  }
86
86
 
87
+ /** Counts non-expired entries whose key starts with `prefix` - used for per-guild breakdowns of a `guildId:userId`-keyed cache. */
88
+ countByPrefix(prefix) {
89
+ const now = Date.now();
90
+ let count = 0;
91
+ for (const [key, entry] of this.store) {
92
+ if (key.startsWith(prefix) && now <= entry.expiresAt) count++;
93
+ }
94
+ return count;
95
+ }
96
+
87
97
  destroy() {
88
98
  if (this._sweeper) clearInterval(this._sweeper);
89
99
  }
@@ -13,18 +13,20 @@ const EventEmitter = require("events");
13
13
  * verification level, revert a role's permissions) since those are
14
14
  * time-critical and aren't about penalizing one member.
15
15
  *
16
- * enable()/disable() are the only lifecycle a layer needs to implement -
17
- * disable() fully detaches every listener the layer attached, so a disabled
18
- * layer does zero work rather than quietly early-returning inside a handler
19
- * that's still subscribed.
16
+ * Enabled state is per-guild, not global: one bot process can serve many
17
+ * guilds, each with its own independent set of active layers. Discord's
18
+ * gateway doesn't support attaching a listener scoped to a single guild, so
19
+ * the underlying event listener is attached exactly once, ever
20
+ * (attach()), and every handler must gate its actual work behind
21
+ * `this.isEnabled(guildId)` as its first check - a disabled guild does zero
22
+ * work past that check, an enabled one proceeds normally.
20
23
  */
21
24
  class Layer extends EventEmitter {
22
25
  constructor(name, frame) {
23
26
  super();
24
27
  this.name = name;
25
28
  this.frame = frame;
26
- this.enabled = false;
27
- this._listeners = [];
29
+ this.enabledGuilds = new Set();
28
30
  }
29
31
 
30
32
  get client() {
@@ -35,34 +37,37 @@ class Layer extends EventEmitter {
35
37
  return this.frame.config[this.name] || {};
36
38
  }
37
39
 
40
+ isEnabled(guildId) {
41
+ return this.enabledGuilds.has(guildId);
42
+ }
43
+
38
44
  _listen(emitter, event, handler) {
39
45
  emitter.on(event, handler);
40
- this._listeners.push([emitter, event, handler]);
41
46
  }
42
47
 
43
- enable() {
44
- if (this.enabled) return this;
45
- this.enabled = true;
46
- this.onEnable();
47
- this.frame.emit("layerToggled", { layer: this.name, enabled: true });
48
+ enable(guildId) {
49
+ const already = this.enabledGuilds.has(guildId);
50
+ this.enabledGuilds.add(guildId);
51
+ if (!already) {
52
+ this.frame._persistGuildState(guildId);
53
+ this.frame.emit("layerToggled", { layer: this.name, guildId, enabled: true });
54
+ }
48
55
  return this;
49
56
  }
50
57
 
51
- disable() {
52
- if (!this.enabled) return this;
53
- this.enabled = false;
54
- for (const [emitter, event, handler] of this._listeners) {
55
- emitter.removeListener(event, handler);
58
+ disable(guildId) {
59
+ const had = this.enabledGuilds.delete(guildId);
60
+ if (had) {
61
+ this.frame._persistGuildState(guildId);
62
+ this.frame.emit("layerToggled", { layer: this.name, guildId, enabled: false });
56
63
  }
57
- this._listeners = [];
58
- this.onDisable();
59
- this.frame.emit("layerToggled", { layer: this.name, enabled: false });
60
64
  return this;
61
65
  }
62
66
 
63
- // Subclasses override these two hooks.
64
- onEnable() {}
65
- onDisable() {}
67
+ // Subclasses override this: attach event listeners exactly once here
68
+ // (called once per layer, at GlassFrame construction). Gate all real work
69
+ // behind `this.isEnabled(guildId)`.
70
+ attach() {}
66
71
  }
67
72
 
68
73
  module.exports = Layer;
@@ -3,19 +3,31 @@
3
3
  /**
4
4
  * Lightweight internal metrics collector - no external dependency, no
5
5
  * network call, just counters and a small rolling latency sample per event
6
- * type. Exposed via `frame.getMetrics()` / `!gf metrics` so you can see
7
- * whether the protocol is keeping up on a busy server without wiring in a
8
- * separate APM tool.
6
+ * type, plus a lightweight per-guild activity tally. Exposed via
7
+ * `frame.getMetrics()` / `!gf metrics` (per-server and bot-wide) and the
8
+ * owner-only `!gf engine` dashboard, so you can see whether the protocol is
9
+ * keeping up - and which server is generating the most work - without
10
+ * wiring in a separate APM tool.
9
11
  */
10
12
  class PerformanceMonitor {
11
13
  constructor() {
12
14
  this.startedAt = Date.now();
13
15
  this.eventCounts = {};
14
16
  this.latencies = {}; // name -> last 50 durations in ms
17
+ this.guildActivity = new Map(); // guildId -> event count since startup
15
18
  }
16
19
 
17
- recordEvent(name) {
20
+ /** `guildId` is optional - pass it from any per-guild handler to also tally that guild's activity. */
21
+ recordEvent(name, guildId) {
18
22
  this.eventCounts[name] = (this.eventCounts[name] || 0) + 1;
23
+ if (guildId) {
24
+ this.guildActivity.set(guildId, (this.guildActivity.get(guildId) || 0) + 1);
25
+ }
26
+ }
27
+
28
+ /** Guild IDs ranked by recorded activity, busiest first. */
29
+ topGuildsByActivity(limit = 5) {
30
+ return [...this.guildActivity.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
19
31
  }
20
32
 
21
33
  /** Call at the start of a unit of work; call the returned function when it finishes. */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- const VERSION = "1.2.1";
3
+ const VERSION = "2.1.0";
4
4
  const NAME = "GlassFrame Protocol";
5
5
  const RELEASED = "2026-07-31";
6
6
 
@@ -29,21 +29,21 @@ class AIModerationLayer extends Layer {
29
29
  return this.frame.groqClient;
30
30
  }
31
31
 
32
- onEnable() {
32
+ attach() {
33
33
  if (!this.groq?.available) {
34
34
  this.frame.emit("warning", {
35
35
  layer: "aiModeration",
36
- message: "No Groq API keys configured - layer is active but will stay idle. Set aiModeration.apiKeys in config."
36
+ message: "No Groq API keys configured - layer can be armed but will stay idle. Set aiModeration.apiKeys in config."
37
37
  });
38
38
  }
39
39
  this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
40
40
  }
41
41
 
42
- onDisable() {}
43
-
44
42
  async _handle(message) {
45
43
  if (!message.guild || message.author.bot || !message.content) return;
44
+ if (!this.isEnabled(message.guild.id)) return;
46
45
  if (!this.groq?.available) return;
46
+ this.frame.performance.recordEvent("aiModeration.messageCreate", message.guild.id);
47
47
 
48
48
  const cfg = this.config;
49
49
  const local = classifyMessage(message.content);
@@ -19,7 +19,10 @@ const EVENT_MAP = {
19
19
  [AuditLogEvent.EmojiCreate]: "emojiCreate",
20
20
  [AuditLogEvent.EmojiDelete]: "emojiDelete",
21
21
  [AuditLogEvent.StickerCreate]: "stickerCreate",
22
- [AuditLogEvent.StickerDelete]: "stickerDelete"
22
+ [AuditLogEvent.StickerDelete]: "stickerDelete",
23
+ [AuditLogEvent.MemberRoleUpdate]: "memberRoleUpdate",
24
+ [AuditLogEvent.ChannelOverwriteCreate]: "channelOverwriteCreate",
25
+ [AuditLogEvent.ChannelOverwriteUpdate]: "channelOverwriteUpdate"
23
26
  };
24
27
 
25
28
  class AntiNukeLayer extends Layer {
@@ -33,22 +36,24 @@ class AntiNukeLayer extends Layer {
33
36
  this.webhookMessageCounts = new Map(); // webhookId -> timestamps[]
34
37
  }
35
38
 
36
- onEnable() {
39
+ attach() {
37
40
  this._listen(this.client, "guildAuditLogEntryCreate", (entry, guild) =>
38
41
  this._handleEntry(entry, guild).catch(() => {})
39
42
  );
40
43
  if (this.config.webhookAbuse.enabled) {
41
44
  this._listen(this.client, "messageCreate", (message) => this._handleWebhookMessage(message).catch(() => {}));
42
45
  }
43
- // Give every guild an immediate role audit the moment AntiNuke is armed.
44
- for (const guild of this.client.guilds.cache.values()) {
45
- this._auditRoles(guild).catch(() => {});
46
- }
47
46
  }
48
47
 
49
- onDisable() {
50
- this.actionLog.clear();
51
- this.webhookMessageCounts.clear();
48
+ /** Give a guild an immediate role audit the moment AntiNuke is armed for it. */
49
+ enable(guildId) {
50
+ const wasEnabled = this.isEnabled(guildId);
51
+ super.enable(guildId);
52
+ if (!wasEnabled) {
53
+ const guild = this.client.guilds.cache.get(guildId);
54
+ if (guild) this._auditRoles(guild).catch(() => {});
55
+ }
56
+ return this;
52
57
  }
53
58
 
54
59
  _record(guildId, executorId, type) {
@@ -66,12 +71,13 @@ class AntiNukeLayer extends Layer {
66
71
  async _handleEntry(entry, guild) {
67
72
  const type = EVENT_MAP[entry.action];
68
73
  if (!type) return;
69
- this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}`);
74
+ if (!this.isEnabled(guild.id)) return;
75
+ this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}`, guild.id);
70
76
 
71
77
  const executor = entry.executor;
72
78
  if (!executor || executor.id === this.client.user.id) return;
73
79
  if (executor.id === guild.ownerId) return;
74
- if (this.frame.whitelist.has(executor.id)) return;
80
+ if (this.frame.isWhitelisted(guild.id, executor.id)) return;
75
81
 
76
82
  if (type === "roleUpdate" && this.config.watchDangerousGrants) {
77
83
  await this._checkDangerousGrant(entry, guild, executor);
@@ -82,6 +88,12 @@ class AntiNukeLayer extends Layer {
82
88
  if (type === "inviteCreate" && this.config.watchInviteAbuse) {
83
89
  await this._checkInviteAbuse(entry, guild, executor);
84
90
  }
91
+ if (type === "memberRoleUpdate" && this.config.watchDangerousGrants) {
92
+ await this._checkMemberRoleGrant(entry, guild, executor);
93
+ }
94
+ if ((type === "channelOverwriteCreate" || type === "channelOverwriteUpdate") && this.config.watchOverwriteAbuse) {
95
+ await this._checkChannelOverwriteAbuse(entry, guild, executor);
96
+ }
85
97
  if (type === "webhookCreate" && this.config.webhookAbuse.enabled) {
86
98
  this.recentWebhooks.set(
87
99
  entry.targetId,
@@ -90,11 +102,10 @@ class AntiNukeLayer extends Layer {
90
102
  );
91
103
  }
92
104
 
93
- // Invite creation doesn't count toward the generic burst threshold below -
94
- // a legitimate server creates invites routinely, so it's special-cased
95
- // above (and only escalated for non-staff + unrestricted configs)
96
- // instead of contributing to a raw count.
97
- if (type === "inviteCreate") return;
105
+ // These are special-cased above instead of contributing to a raw burst
106
+ // count - a single grant/overwrite change is worth flagging immediately
107
+ // on its own merits, not after N repeats.
108
+ if (type === "inviteCreate" || type === "memberRoleUpdate" || type === "channelOverwriteCreate" || type === "channelOverwriteUpdate") return;
98
109
 
99
110
  const count = this._record(guild.id, executor.id, type);
100
111
  const threshold = this.config.thresholds[type];
@@ -135,6 +146,7 @@ class AntiNukeLayer extends Layer {
135
146
  const role = guild.roles.cache.get(entry.targetId);
136
147
 
137
148
  await this.frame.logger.log(guild, {
149
+ layer: this.name,
138
150
  level: "alert",
139
151
  title: "Dangerous Permission Grant Detected",
140
152
  description: `${executor.tag} granted **${gained.join(", ")}** to role ${role ? role.name : entry.targetId}.`,
@@ -159,6 +171,7 @@ class AntiNukeLayer extends Layer {
159
171
  if (!changed.length) return;
160
172
 
161
173
  await this.frame.logger.log(guild, {
174
+ layer: this.name,
162
175
  level: "warn",
163
176
  title: "Server Identity Changed",
164
177
  description: `${executor.tag} changed: ${changed.map((c) => c.key).join(", ")}.`,
@@ -190,6 +203,7 @@ class AntiNukeLayer extends Layer {
190
203
  if (trust === "HIGH" || trust === "PROTECTED") return;
191
204
 
192
205
  await this.frame.logger.log(guild, {
206
+ layer: this.name,
193
207
  level: "warn",
194
208
  title: "Unrestricted Invite Created By Non-Staff Member",
195
209
  description: `${executor.tag} (trust: ${trust}) created an invite with no expiry and no use limit.`,
@@ -197,6 +211,86 @@ class AntiNukeLayer extends Layer {
197
211
  });
198
212
  }
199
213
 
214
+ /**
215
+ * RoleUpdate (above) catches a role's own permissions changing - this
216
+ * catches the other half: a specific MEMBER being handed a role that
217
+ * already carries dangerous permissions. Missing this meant someone
218
+ * with ManageRoles could quietly promote themselves (or an accomplice)
219
+ * without ever touching a role's definition.
220
+ */
221
+ async _checkMemberRoleGrant(entry, guild, executor) {
222
+ const added = (entry.changes || []).find((c) => c.key === "$add");
223
+ if (!added || !Array.isArray(added.new) || !added.new.length) return;
224
+
225
+ const dangerous = this.frame.config.roleAnalysis.dangerousPermissions;
226
+ const dangerousRoles = added.new
227
+ .map((r) => guild.roles.cache.get(r.id))
228
+ .filter((role) => role && dangerous.some((p) => role.permissions.has(PermissionsBitField.Flags[p])));
229
+ if (!dangerousRoles.length) return;
230
+
231
+ let executorMember = null;
232
+ try {
233
+ executorMember = await guild.members.fetch(executor.id);
234
+ } catch {
235
+ /* left already */
236
+ }
237
+ const trust = executorMember ? this.frame.roleAnalyzer.trustLevel(executorMember) : "LOW";
238
+ if (trust === "HIGH" || trust === "PROTECTED") return;
239
+
240
+ await this.frame.logger.log(guild, {
241
+ layer: this.name,
242
+ level: "alert",
243
+ title: "Dangerous Role Granted To A Member",
244
+ description: `${executor.tag} (trust: ${trust}) gave <@${entry.targetId}> the role(s): ${dangerousRoles
245
+ .map((r) => r.name)
246
+ .join(", ")}.`,
247
+ dedupeKey: `antinuke:member-grant:${entry.targetId}`
248
+ });
249
+ }
250
+
251
+ /**
252
+ * A nuke doesn't have to delete anything - locking @everyone out of a
253
+ * public channel, or opening a private one up to @everyone, does the
254
+ * same damage through permission overwrites instead. Only @everyone's
255
+ * own overwrite is watched here; a role- or member-specific overwrite
256
+ * change is routine channel administration and not worth flagging.
257
+ */
258
+ async _checkChannelOverwriteAbuse(entry, guild, executor) {
259
+ if (!entry.extra || String(entry.extra.id) !== guild.id) return;
260
+
261
+ const allowChange = (entry.changes || []).find((c) => c.key === "allow");
262
+ const denyChange = (entry.changes || []).find((c) => c.key === "deny");
263
+ if (!allowChange && !denyChange) return;
264
+
265
+ const oldAllow = new PermissionsBitField(BigInt(allowChange?.old ?? 0));
266
+ const newAllow = new PermissionsBitField(BigInt(allowChange?.new ?? 0));
267
+ const oldDeny = new PermissionsBitField(BigInt(denyChange?.old ?? 0));
268
+ const newDeny = new PermissionsBitField(BigInt(denyChange?.new ?? 0));
269
+
270
+ const view = PermissionsBitField.Flags.ViewChannel;
271
+ const nowGrantsView = !oldAllow.has(view) && newAllow.has(view);
272
+ const nowBlocksView = !oldDeny.has(view) && newDeny.has(view);
273
+ if (!nowGrantsView && !nowBlocksView) return;
274
+
275
+ let executorMember = null;
276
+ try {
277
+ executorMember = await guild.members.fetch(executor.id);
278
+ } catch {
279
+ /* left already */
280
+ }
281
+ const trust = executorMember ? this.frame.roleAnalyzer.trustLevel(executorMember) : "LOW";
282
+ if (trust === "HIGH" || trust === "PROTECTED") return;
283
+
284
+ const channel = guild.channels.cache.get(entry.targetId);
285
+ await this.frame.logger.log(guild, {
286
+ layer: this.name,
287
+ level: "alert",
288
+ title: nowBlocksView ? "Channel Locked Out For Everyone" : "Private Channel Opened To Everyone",
289
+ description: `${executor.tag} (trust: ${trust}) changed @everyone's access to ${channel ? channel.name : entry.targetId}.`,
290
+ dedupeKey: `antinuke:overwrite:${entry.targetId}`
291
+ });
292
+ }
293
+
200
294
  /**
201
295
  * Webhook messages never carry a guild member, so BasicSecurityLayer's
202
296
  * per-author spam buffer can't see them - a "create webhook, then flood
@@ -206,6 +300,7 @@ class AntiNukeLayer extends Layer {
206
300
  */
207
301
  async _handleWebhookMessage(message) {
208
302
  if (!message.webhookId || !message.guild) return;
303
+ if (!this.isEnabled(message.guild.id)) return;
209
304
  const info = this.recentWebhooks.get(message.webhookId);
210
305
  if (!info) return;
211
306
 
@@ -228,6 +323,7 @@ class AntiNukeLayer extends Layer {
228
323
  }
229
324
 
230
325
  await this.frame.logger.log(guild, {
326
+ layer: this.name,
231
327
  level: "alert",
232
328
  title: "Webhook Message Flood - Webhook Removed",
233
329
  description: `A recently created webhook sent ${list.length}+ messages inside ${cfg.windowMs / 1000}s and has been deleted.`,
@@ -267,6 +363,7 @@ class AntiNukeLayer extends Layer {
267
363
  if (!flags.length) return;
268
364
 
269
365
  await this.frame.logger.log(guild, {
366
+ layer: this.name,
270
367
  level: "warn",
271
368
  title: "Role Audit - Possible Impersonation/Permission Mismatch",
272
369
  description: flags.map((f) => `**${f.name}** (${f.roleId}) - ${f.reason}`).join("\n"),