averecion-lite 1.3.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/README.md +161 -0
- package/dashboard/dash.css +1085 -0
- package/dashboard/dash.js +898 -0
- package/dashboard/index.html +312 -0
- package/dashboard/landing.html +360 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +409 -0
- package/dist/hooks.d.ts +25 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +68 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/injectionGuard.d.ts +9 -0
- package/dist/injectionGuard.d.ts.map +1 -0
- package/dist/injectionGuard.js +16 -0
- package/dist/log-watcher.d.ts +26 -0
- package/dist/log-watcher.d.ts.map +1 -0
- package/dist/log-watcher.js +397 -0
- package/dist/metrics.d.ts +53 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +58 -0
- package/dist/policy.d.ts +11 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +60 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +226 -0
- package/dist/src/capability-manifest.d.ts +16 -0
- package/dist/src/capability-manifest.d.ts.map +1 -0
- package/dist/src/capability-manifest.js +228 -0
- package/dist/src/http-proxy.d.ts +4 -0
- package/dist/src/http-proxy.d.ts.map +1 -0
- package/dist/src/http-proxy.js +266 -0
- package/dist/src/risk-engine.d.ts +43 -0
- package/dist/src/risk-engine.d.ts.map +1 -0
- package/dist/src/risk-engine.js +258 -0
- package/dist/src/shell-wrapper.d.ts +3 -0
- package/dist/src/shell-wrapper.d.ts.map +1 -0
- package/dist/src/shell-wrapper.js +264 -0
- package/dist/storage.d.ts +28 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +144 -0
- package/examples/INTEGRATION.md +162 -0
- package/examples/claude-desktop-agent.json +32 -0
- package/examples/clawdbot-agent.json +44 -0
- package/examples/custom-agent.json +20 -0
- package/lite-policy.json +5 -0
- package/package.json +56 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
let SECRET = localStorage.getItem("lite_secret");
|
|
3
|
+
let timelineChart = null;
|
|
4
|
+
let ws = null;
|
|
5
|
+
let reconnectAttempts = 0;
|
|
6
|
+
const tooltip = document.getElementById("tooltip");
|
|
7
|
+
let notificationsEnabled = localStorage.getItem("notifications_enabled") === "true";
|
|
8
|
+
const feedback = JSON.parse(localStorage.getItem("clawguard_feedback") || "{}");
|
|
9
|
+
|
|
10
|
+
// Notification system
|
|
11
|
+
async function requestNotificationPermission() {
|
|
12
|
+
if (!("Notification" in window)) {
|
|
13
|
+
console.log("Browser does not support notifications");
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (Notification.permission === "granted") {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (Notification.permission !== "denied") {
|
|
22
|
+
const permission = await Notification.requestPermission();
|
|
23
|
+
return permission === "granted";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function showNotification(title, body, isCritical = false) {
|
|
30
|
+
if (!notificationsEnabled || Notification.permission !== "granted") return;
|
|
31
|
+
|
|
32
|
+
const notification = new Notification(title, {
|
|
33
|
+
body,
|
|
34
|
+
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦞</text></svg>",
|
|
35
|
+
tag: isCritical ? "critical" : "info",
|
|
36
|
+
requireInteraction: isCritical
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
notification.onclick = () => {
|
|
40
|
+
window.focus();
|
|
41
|
+
notification.close();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (!isCritical) {
|
|
45
|
+
setTimeout(() => notification.close(), 5000);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function saveFeedback(eventId, helpful) {
|
|
50
|
+
feedback[eventId] = { helpful, ts: new Date().toISOString() };
|
|
51
|
+
localStorage.setItem("clawguard_feedback", JSON.stringify(feedback));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getFeedbackStats() {
|
|
55
|
+
const entries = Object.values(feedback);
|
|
56
|
+
const helpful = entries.filter(e => e.helpful).length;
|
|
57
|
+
const notHelpful = entries.filter(e => !e.helpful).length;
|
|
58
|
+
return { helpful, notHelpful, total: entries.length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const screens = {
|
|
62
|
+
onboarding: document.getElementById("onboarding"),
|
|
63
|
+
dashboard: document.getElementById("dashboard")
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const THREAT_CONTEXT = {
|
|
67
|
+
"shell.exec": {
|
|
68
|
+
title: "Terminal Command Blocked",
|
|
69
|
+
desc: "Attackers sometimes trick AI into running dangerous commands like deleting files or stealing data."
|
|
70
|
+
},
|
|
71
|
+
"fs.write": {
|
|
72
|
+
title: "File Write Blocked",
|
|
73
|
+
desc: "Writing files can be risky if an attacker tricks the AI into overwriting important files or creating malware."
|
|
74
|
+
},
|
|
75
|
+
"fs.delete": {
|
|
76
|
+
title: "File Delete Blocked",
|
|
77
|
+
desc: "Deleting files could cause data loss. This was stopped to protect your system."
|
|
78
|
+
},
|
|
79
|
+
"http.request": {
|
|
80
|
+
title: "Network Request Reviewed",
|
|
81
|
+
desc: "Outgoing requests can leak your data to attackers. This request was checked for safety."
|
|
82
|
+
},
|
|
83
|
+
"env.read": {
|
|
84
|
+
title: "Secrets Access Blocked",
|
|
85
|
+
desc: "Someone tried to access your API keys or passwords. This could lead to credential theft."
|
|
86
|
+
},
|
|
87
|
+
"promptInjection": {
|
|
88
|
+
title: "Prompt Injection Detected!",
|
|
89
|
+
desc: "An attacker tried to manipulate your AI by hiding instructions in data. This is a known attack vector."
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function showScreen(name) {
|
|
94
|
+
Object.values(screens).forEach(s => s.classList.add("hidden"));
|
|
95
|
+
screens[name].classList.remove("hidden");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function setWizardStep(step) {
|
|
99
|
+
document.querySelectorAll(".step").forEach((el, i) => {
|
|
100
|
+
const stepNum = i + 1;
|
|
101
|
+
el.classList.remove("active", "done");
|
|
102
|
+
if (stepNum < step) el.classList.add("done");
|
|
103
|
+
if (stepNum === step) el.classList.add("active");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
document.querySelectorAll(".wizard-panel").forEach(panel => {
|
|
107
|
+
panel.classList.remove("active");
|
|
108
|
+
if (panel.dataset.panel === String(step)) panel.classList.add("active");
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
document.getElementById("btn-connect")?.addEventListener("click", () => {
|
|
113
|
+
const input = document.getElementById("secret-input");
|
|
114
|
+
const secret = input.value.trim();
|
|
115
|
+
if (secret.length < 10) {
|
|
116
|
+
input.style.borderColor = "#ef4444";
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
SECRET = secret;
|
|
120
|
+
localStorage.setItem("lite_secret", SECRET);
|
|
121
|
+
setWizardStep(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
document.querySelectorAll(".level-card").forEach(card => {
|
|
125
|
+
card.addEventListener("click", () => {
|
|
126
|
+
document.querySelectorAll(".level-card").forEach(c => c.classList.remove("selected"));
|
|
127
|
+
card.classList.add("selected");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
document.getElementById("btn-configure")?.addEventListener("click", () => {
|
|
132
|
+
const level = document.querySelector('input[name="protection"]:checked')?.value || "balanced";
|
|
133
|
+
localStorage.setItem("protection_level", level);
|
|
134
|
+
setWizardStep(3);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
document.getElementById("btn-go")?.addEventListener("click", () => {
|
|
138
|
+
showScreen("dashboard");
|
|
139
|
+
loadDashboard();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
async function fetchMetrics() {
|
|
143
|
+
try {
|
|
144
|
+
const headers = {};
|
|
145
|
+
if (SECRET) headers["X-Lite-Secret"] = SECRET;
|
|
146
|
+
const response = await fetch("/lite-metrics", { headers });
|
|
147
|
+
if (!response.ok) throw new Error("Fetch failed");
|
|
148
|
+
return await response.json();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error("Failed to fetch metrics:", err);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function friendlyAction(event) {
|
|
156
|
+
const tool = event.tool || "action";
|
|
157
|
+
const reason = event.reason || "";
|
|
158
|
+
|
|
159
|
+
if (event.decision === "approved") {
|
|
160
|
+
return { icon: "✓", class: "approved", text: `Your bot used ${tool}`, context: null };
|
|
161
|
+
}
|
|
162
|
+
if (event.decision === "blocked") {
|
|
163
|
+
let context = THREAT_CONTEXT[tool] || null;
|
|
164
|
+
if (reason.includes("promptInjection")) {
|
|
165
|
+
context = THREAT_CONTEXT.promptInjection;
|
|
166
|
+
return { icon: "🛡️", class: "blocked attack", text: `Caught attack: Prompt injection attempt`, context, isAttack: true };
|
|
167
|
+
}
|
|
168
|
+
if (reason.includes("highRisk")) {
|
|
169
|
+
return { icon: "⚠️", class: "blocked", text: `Blocked risky action: ${tool}`, context };
|
|
170
|
+
}
|
|
171
|
+
return { icon: "✗", class: "blocked", text: `Blocked: ${tool}`, context };
|
|
172
|
+
}
|
|
173
|
+
if (event.decision === "manual") {
|
|
174
|
+
return { icon: "👆", class: "manual", text: `You approved ${tool}`, context: null };
|
|
175
|
+
}
|
|
176
|
+
return { icon: "•", class: "approved", text: tool, context: null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function formatTime(ts) {
|
|
180
|
+
const date = new Date(ts);
|
|
181
|
+
const now = new Date();
|
|
182
|
+
const diff = (now - date) / 1000;
|
|
183
|
+
|
|
184
|
+
if (diff < 60) return "Just now";
|
|
185
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
186
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
187
|
+
return date.toLocaleDateString();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function updateDashboard(metrics) {
|
|
191
|
+
document.getElementById("stat-approved").textContent = metrics.kpis.approved || 0;
|
|
192
|
+
document.getElementById("stat-blocked").textContent = metrics.kpis.blocked || 0;
|
|
193
|
+
document.getElementById("stat-manual").textContent = metrics.kpis.manualApproved || 0;
|
|
194
|
+
|
|
195
|
+
const globalStatus = document.getElementById("global-status");
|
|
196
|
+
if (metrics.kpis.blocked > 5 || metrics.kpis.promptInjectionDetected > 0) {
|
|
197
|
+
globalStatus.textContent = "⚠️ Needs Attention";
|
|
198
|
+
globalStatus.className = "status-badge warning";
|
|
199
|
+
} else {
|
|
200
|
+
globalStatus.textContent = "✓ Protected";
|
|
201
|
+
globalStatus.className = "status-badge protected";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const checkLocal = document.getElementById("check-local");
|
|
205
|
+
const checkSecret = document.getElementById("check-secret");
|
|
206
|
+
const checkInjection = document.getElementById("check-injection");
|
|
207
|
+
|
|
208
|
+
if (checkLocal) checkLocal.className = "safety-item " + (metrics.instance.dashboardLocalOnly ? "pass" : "fail");
|
|
209
|
+
if (checkSecret) checkSecret.className = "safety-item " + (metrics.instance.secretsEnvOnly ? "pass" : "fail");
|
|
210
|
+
if (checkInjection) checkInjection.className = "safety-item pass";
|
|
211
|
+
|
|
212
|
+
const activityList = document.getElementById("activity-list");
|
|
213
|
+
if (metrics.lastActions && metrics.lastActions.length > 0) {
|
|
214
|
+
activityList.innerHTML = metrics.lastActions.slice(0, 5).map((event, index) => {
|
|
215
|
+
const friendly = friendlyAction(event);
|
|
216
|
+
const hasContext = friendly.context !== null;
|
|
217
|
+
const contextClass = friendly.isAttack ? "attack" : "";
|
|
218
|
+
const eventId = event.id || `${event.ts}-${index}`;
|
|
219
|
+
const showFeedback = event.decision === "blocked" || friendly.isAttack;
|
|
220
|
+
const alreadyFeedback = feedback[eventId];
|
|
221
|
+
|
|
222
|
+
const feedbackHtml = showFeedback ? (alreadyFeedback ?
|
|
223
|
+
`<div class="activity-feedback"><span class="feedback-thanks">Thanks for your feedback!</span></div>` :
|
|
224
|
+
`<div class="activity-feedback" data-event-id="${eventId}">
|
|
225
|
+
<span class="feedback-label">Was this flag helpful?</span>
|
|
226
|
+
<button class="feedback-btn helpful" data-helpful="true" data-testid="feedback-helpful-${eventId}">👍</button>
|
|
227
|
+
<button class="feedback-btn not-helpful" data-helpful="false" data-testid="feedback-not-helpful-${eventId}">👎</button>
|
|
228
|
+
</div>`
|
|
229
|
+
) : "";
|
|
230
|
+
|
|
231
|
+
if (hasContext) {
|
|
232
|
+
return `
|
|
233
|
+
<div class="activity-item has-context" data-event-id="${eventId}">
|
|
234
|
+
<div class="activity-header">
|
|
235
|
+
<div class="activity-icon ${friendly.class}">${friendly.icon}</div>
|
|
236
|
+
<div class="activity-content">
|
|
237
|
+
<div class="activity-text">${friendly.text}</div>
|
|
238
|
+
<div class="activity-time">${formatTime(event.ts)}</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="activity-context ${contextClass}">
|
|
242
|
+
<div class="activity-context-title">${friendly.context.title}</div>
|
|
243
|
+
<div class="activity-context-desc">${friendly.context.desc}</div>
|
|
244
|
+
</div>
|
|
245
|
+
${feedbackHtml}
|
|
246
|
+
</div>
|
|
247
|
+
`;
|
|
248
|
+
}
|
|
249
|
+
return `
|
|
250
|
+
<div class="activity-item" data-event-id="${eventId}">
|
|
251
|
+
<div class="activity-icon ${friendly.class}">${friendly.icon}</div>
|
|
252
|
+
<div class="activity-content">
|
|
253
|
+
<div class="activity-text">${friendly.text}</div>
|
|
254
|
+
<div class="activity-time">${formatTime(event.ts)}</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
`;
|
|
258
|
+
}).join("");
|
|
259
|
+
|
|
260
|
+
// Attach feedback handlers for initial load
|
|
261
|
+
activityList.querySelectorAll(".feedback-btn").forEach(btn => {
|
|
262
|
+
btn.addEventListener("click", () => {
|
|
263
|
+
const eventId = btn.closest(".activity-feedback").dataset.eventId;
|
|
264
|
+
const helpful = btn.dataset.helpful === "true";
|
|
265
|
+
saveFeedback(eventId, helpful);
|
|
266
|
+
const feedbackDiv = btn.closest(".activity-feedback");
|
|
267
|
+
if (feedbackDiv) {
|
|
268
|
+
feedbackDiv.innerHTML = `<span class="feedback-thanks">Thanks for your feedback!</span>`;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
updateProtectionScore(metrics);
|
|
275
|
+
updatePendingApprovals(metrics);
|
|
276
|
+
|
|
277
|
+
updateChart(metrics.timeline);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function updateChart(timeline) {
|
|
281
|
+
const ctx = document.getElementById("chart-timeline").getContext("2d");
|
|
282
|
+
|
|
283
|
+
if (timelineChart) {
|
|
284
|
+
timelineChart.destroy();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
timelineChart = new Chart(ctx, {
|
|
288
|
+
type: "line",
|
|
289
|
+
data: {
|
|
290
|
+
labels: timeline.hours,
|
|
291
|
+
datasets: [
|
|
292
|
+
{
|
|
293
|
+
label: "Approved",
|
|
294
|
+
data: timeline.approved,
|
|
295
|
+
borderColor: "#22c55e",
|
|
296
|
+
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
|
297
|
+
fill: true,
|
|
298
|
+
tension: 0.4,
|
|
299
|
+
pointRadius: 0
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
label: "Blocked",
|
|
303
|
+
data: timeline.blocked,
|
|
304
|
+
borderColor: "#ef4444",
|
|
305
|
+
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
|
306
|
+
fill: true,
|
|
307
|
+
tension: 0.4,
|
|
308
|
+
pointRadius: 0
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
},
|
|
312
|
+
options: {
|
|
313
|
+
responsive: true,
|
|
314
|
+
plugins: {
|
|
315
|
+
legend: {
|
|
316
|
+
position: "top",
|
|
317
|
+
labels: { color: "#a0a0b0", usePointStyle: true }
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
scales: {
|
|
321
|
+
x: {
|
|
322
|
+
ticks: { color: "#606070", maxTicksLimit: 8 },
|
|
323
|
+
grid: { color: "rgba(255,255,255,0.05)" }
|
|
324
|
+
},
|
|
325
|
+
y: {
|
|
326
|
+
beginAtZero: true,
|
|
327
|
+
ticks: { color: "#606070" },
|
|
328
|
+
grid: { color: "rgba(255,255,255,0.05)" }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function loadDashboard() {
|
|
336
|
+
const metrics = await fetchMetrics();
|
|
337
|
+
if (metrics) {
|
|
338
|
+
updateDashboard(metrics);
|
|
339
|
+
hideError();
|
|
340
|
+
} else {
|
|
341
|
+
showError("Couldn't connect to your bot. Check that Averecion Lite is running.");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function showError(message) {
|
|
346
|
+
let errorEl = document.getElementById("error-banner");
|
|
347
|
+
if (!errorEl) {
|
|
348
|
+
errorEl = document.createElement("div");
|
|
349
|
+
errorEl.id = "error-banner";
|
|
350
|
+
errorEl.style.cssText = "background:#ef4444;color:white;padding:1rem;text-align:center;position:fixed;top:0;left:0;right:0;z-index:100;";
|
|
351
|
+
document.body.prepend(errorEl);
|
|
352
|
+
}
|
|
353
|
+
errorEl.textContent = "⚠️ " + message;
|
|
354
|
+
errorEl.style.display = "block";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function hideError() {
|
|
358
|
+
const errorEl = document.getElementById("error-banner");
|
|
359
|
+
if (errorEl) errorEl.style.display = "none";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function updateProtectionScore(metrics) {
|
|
363
|
+
let score = 0;
|
|
364
|
+
const bars = {
|
|
365
|
+
local: document.getElementById("bar-local"),
|
|
366
|
+
secret: document.getElementById("bar-secret"),
|
|
367
|
+
injection: document.getElementById("bar-injection"),
|
|
368
|
+
approval: document.getElementById("bar-approval")
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (metrics.instance.dashboardLocalOnly) {
|
|
372
|
+
score++;
|
|
373
|
+
bars.local?.classList.add("active");
|
|
374
|
+
bars.local?.classList.remove("inactive");
|
|
375
|
+
} else {
|
|
376
|
+
bars.local?.classList.remove("active");
|
|
377
|
+
bars.local?.classList.add("inactive");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (metrics.instance.secretsEnvOnly) {
|
|
381
|
+
score++;
|
|
382
|
+
bars.secret?.classList.add("active");
|
|
383
|
+
bars.secret?.classList.remove("inactive");
|
|
384
|
+
} else {
|
|
385
|
+
bars.secret?.classList.remove("active");
|
|
386
|
+
bars.secret?.classList.add("inactive");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
score++;
|
|
390
|
+
bars.injection?.classList.add("active");
|
|
391
|
+
|
|
392
|
+
if (localStorage.getItem("protection_level") !== "relaxed") {
|
|
393
|
+
score++;
|
|
394
|
+
bars.approval?.classList.add("active");
|
|
395
|
+
bars.approval?.classList.remove("inactive");
|
|
396
|
+
} else {
|
|
397
|
+
bars.approval?.classList.remove("active");
|
|
398
|
+
bars.approval?.classList.add("inactive");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const scoreEl = document.getElementById("score-value");
|
|
402
|
+
if (scoreEl) {
|
|
403
|
+
scoreEl.textContent = `${score}/4`;
|
|
404
|
+
scoreEl.style.color = score === 4 ? "#22c55e" : score >= 2 ? "#f59e0b" : "#ef4444";
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function initTooltips() {
|
|
409
|
+
if (!tooltip) return;
|
|
410
|
+
|
|
411
|
+
document.querySelectorAll("[data-tooltip]").forEach(el => {
|
|
412
|
+
el.addEventListener("mouseenter", (e) => {
|
|
413
|
+
const text = el.getAttribute("data-tooltip");
|
|
414
|
+
tooltip.textContent = text;
|
|
415
|
+
tooltip.classList.add("visible");
|
|
416
|
+
|
|
417
|
+
const rect = el.getBoundingClientRect();
|
|
418
|
+
const left = Math.max(10, Math.min(window.innerWidth - 260, rect.left + (rect.width / 2) - 125));
|
|
419
|
+
tooltip.style.left = left + "px";
|
|
420
|
+
tooltip.style.top = rect.bottom + 8 + "px";
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
el.addEventListener("mouseleave", () => {
|
|
424
|
+
tooltip.classList.remove("visible");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const LEVEL_HINTS = {
|
|
430
|
+
relaxed: "Just block the really dangerous stuff",
|
|
431
|
+
balanced: "Ask me before risky actions",
|
|
432
|
+
strict: "I approve everything important"
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
function initProtectionToggle() {
|
|
436
|
+
const toggleBtns = document.querySelectorAll(".toggle-btn");
|
|
437
|
+
const hintEl = document.getElementById("level-hint");
|
|
438
|
+
const savedLevel = localStorage.getItem("protection_level") || "balanced";
|
|
439
|
+
|
|
440
|
+
toggleBtns.forEach(btn => {
|
|
441
|
+
if (btn.dataset.level === savedLevel) {
|
|
442
|
+
btn.classList.add("active");
|
|
443
|
+
} else {
|
|
444
|
+
btn.classList.remove("active");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
btn.addEventListener("click", async () => {
|
|
448
|
+
const level = btn.dataset.level;
|
|
449
|
+
toggleBtns.forEach(b => b.classList.remove("active"));
|
|
450
|
+
btn.classList.add("active");
|
|
451
|
+
localStorage.setItem("protection_level", level);
|
|
452
|
+
if (hintEl) hintEl.textContent = LEVEL_HINTS[level];
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
const headers = { "Content-Type": "application/json" };
|
|
456
|
+
if (SECRET) headers["X-Lite-Secret"] = SECRET;
|
|
457
|
+
await fetch("/lite-policy", {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers,
|
|
460
|
+
body: JSON.stringify({ level })
|
|
461
|
+
});
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.log("Policy update sent (backend may not support yet)");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
loadDashboard();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (hintEl) hintEl.textContent = LEVEL_HINTS[savedLevel];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function updatePendingApprovals(metrics) {
|
|
474
|
+
const section = document.getElementById("approvals-section");
|
|
475
|
+
const list = document.getElementById("approvals-list");
|
|
476
|
+
const countEl = document.getElementById("approval-count");
|
|
477
|
+
|
|
478
|
+
const pending = metrics.pendingApprovals || [];
|
|
479
|
+
|
|
480
|
+
if (pending.length === 0) {
|
|
481
|
+
section.style.display = "none";
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
section.style.display = "block";
|
|
486
|
+
countEl.textContent = pending.length;
|
|
487
|
+
|
|
488
|
+
list.innerHTML = pending.map(item => `
|
|
489
|
+
<div class="approval-card" data-id="${item.id}">
|
|
490
|
+
<div class="approval-header">
|
|
491
|
+
<span class="approval-icon">⏳</span>
|
|
492
|
+
<span class="approval-tool">${item.tool}</span>
|
|
493
|
+
<span class="approval-time">${formatTime(item.ts)}</span>
|
|
494
|
+
</div>
|
|
495
|
+
<div class="approval-plan">${item.plan || "No description provided"}</div>
|
|
496
|
+
<div class="approval-actions">
|
|
497
|
+
<button class="btn-approve" data-id="${item.id}" data-testid="btn-approve-${item.id}">✓ Approve</button>
|
|
498
|
+
<button class="btn-deny" data-id="${item.id}" data-testid="btn-deny-${item.id}">✗ Deny</button>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
`).join("");
|
|
502
|
+
|
|
503
|
+
list.querySelectorAll(".btn-approve").forEach(btn => {
|
|
504
|
+
btn.addEventListener("click", () => handleApproval(btn.dataset.id, true));
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
list.querySelectorAll(".btn-deny").forEach(btn => {
|
|
508
|
+
btn.addEventListener("click", () => handleApproval(btn.dataset.id, false));
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function handleApproval(id, approved) {
|
|
513
|
+
try {
|
|
514
|
+
const headers = { "Content-Type": "application/json" };
|
|
515
|
+
if (SECRET) headers["X-Lite-Secret"] = SECRET;
|
|
516
|
+
|
|
517
|
+
await fetch("/lite-approval", {
|
|
518
|
+
method: "POST",
|
|
519
|
+
headers,
|
|
520
|
+
body: JSON.stringify({ id, approved })
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
loadDashboard();
|
|
524
|
+
} catch (err) {
|
|
525
|
+
console.error("Approval failed:", err);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function fetchAgents() {
|
|
530
|
+
try {
|
|
531
|
+
const headers = {};
|
|
532
|
+
if (SECRET) headers["X-Lite-Secret"] = SECRET;
|
|
533
|
+
const response = await fetch("/api/agents", { headers });
|
|
534
|
+
if (!response.ok) return [];
|
|
535
|
+
return await response.json();
|
|
536
|
+
} catch (err) {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function getRiskColor(score) {
|
|
542
|
+
if (score >= 70) return "#ef4444";
|
|
543
|
+
if (score >= 40) return "#f59e0b";
|
|
544
|
+
return "#22c55e";
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function getRiskLabel(level) {
|
|
548
|
+
const labels = {
|
|
549
|
+
critical: "🔴 Critical",
|
|
550
|
+
high: "🟠 High",
|
|
551
|
+
medium: "🟡 Medium",
|
|
552
|
+
low: "🟢 Low"
|
|
553
|
+
};
|
|
554
|
+
return labels[level] || level;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function updateAgentsGrid() {
|
|
558
|
+
const grid = document.getElementById("agents-grid");
|
|
559
|
+
const emptyEl = document.getElementById("agents-empty");
|
|
560
|
+
const countEl = document.getElementById("agent-count");
|
|
561
|
+
|
|
562
|
+
if (!grid) return;
|
|
563
|
+
|
|
564
|
+
const agents = await fetchAgents();
|
|
565
|
+
|
|
566
|
+
if (agents.length === 0) {
|
|
567
|
+
if (emptyEl) emptyEl.style.display = "block";
|
|
568
|
+
if (countEl) countEl.textContent = "0";
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (emptyEl) emptyEl.style.display = "none";
|
|
573
|
+
if (countEl) countEl.textContent = agents.length;
|
|
574
|
+
|
|
575
|
+
const agentCards = agents.map(agent => {
|
|
576
|
+
const risk = agent.risk || { overallScore: 0, riskLevel: "low" };
|
|
577
|
+
const capCount = agent.capabilities?.length || 0;
|
|
578
|
+
const framework = agent.framework || "custom";
|
|
579
|
+
|
|
580
|
+
return `
|
|
581
|
+
<div class="agent-card" data-id="${agent.id}" data-testid="agent-${agent.id}">
|
|
582
|
+
<div class="agent-header">
|
|
583
|
+
<span class="agent-icon">${getAgentIcon(framework)}</span>
|
|
584
|
+
<div class="agent-name-wrap">
|
|
585
|
+
<span class="agent-name">${agent.name}</span>
|
|
586
|
+
<span class="agent-framework">${framework}</span>
|
|
587
|
+
</div>
|
|
588
|
+
<span class="agent-risk-badge" style="background: ${getRiskColor(risk.overallScore)}">
|
|
589
|
+
${risk.overallScore}
|
|
590
|
+
</span>
|
|
591
|
+
</div>
|
|
592
|
+
<div class="agent-risk-bar">
|
|
593
|
+
<div class="agent-risk-fill" style="width: ${risk.overallScore}%; background: ${getRiskColor(risk.overallScore)}"></div>
|
|
594
|
+
</div>
|
|
595
|
+
<div class="agent-details">
|
|
596
|
+
<span class="agent-detail">
|
|
597
|
+
<span class="detail-label">Capabilities:</span>
|
|
598
|
+
<span class="detail-value">${capCount}</span>
|
|
599
|
+
</span>
|
|
600
|
+
<span class="agent-detail">
|
|
601
|
+
<span class="detail-label">Risk:</span>
|
|
602
|
+
<span class="detail-value">${getRiskLabel(risk.riskLevel)}</span>
|
|
603
|
+
</span>
|
|
604
|
+
</div>
|
|
605
|
+
${renderCapabilities(agent.capabilities)}
|
|
606
|
+
</div>
|
|
607
|
+
`;
|
|
608
|
+
}).join("");
|
|
609
|
+
|
|
610
|
+
grid.innerHTML = agentCards;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function getAgentIcon(framework) {
|
|
614
|
+
const icons = {
|
|
615
|
+
"clawdbot": "🦞",
|
|
616
|
+
"claude-desktop": "🤖",
|
|
617
|
+
"langchain": "🔗",
|
|
618
|
+
"autogpt": "⚡",
|
|
619
|
+
"custom": "🔧"
|
|
620
|
+
};
|
|
621
|
+
return icons[framework] || "🤖";
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function renderCapabilities(capabilities) {
|
|
625
|
+
if (!capabilities || capabilities.length === 0) return "";
|
|
626
|
+
|
|
627
|
+
const caps = capabilities.slice(0, 4).map(cap => {
|
|
628
|
+
const riskWeight = cap.riskWeight || 0;
|
|
629
|
+
const dotColor = riskWeight >= 70 ? "#ef4444" : riskWeight >= 40 ? "#f59e0b" : "#22c55e";
|
|
630
|
+
return `<span class="cap-tag" style="border-color: ${dotColor}"><span class="cap-dot" style="background: ${dotColor}"></span>${cap.name}</span>`;
|
|
631
|
+
}).join("");
|
|
632
|
+
|
|
633
|
+
const more = capabilities.length > 4 ? `<span class="cap-more">+${capabilities.length - 4} more</span>` : "";
|
|
634
|
+
|
|
635
|
+
return `<div class="agent-caps">${caps}${more}</div>`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// WebSocket connection for real-time updates
|
|
639
|
+
function connectWebSocket() {
|
|
640
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
641
|
+
let wsUrl = `${protocol}//${window.location.host}`;
|
|
642
|
+
|
|
643
|
+
if (SECRET) {
|
|
644
|
+
wsUrl += `?secret=${encodeURIComponent(SECRET)}`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
ws = new WebSocket(wsUrl);
|
|
649
|
+
|
|
650
|
+
ws.onopen = () => {
|
|
651
|
+
console.log("[Clawguard] Real-time connection established");
|
|
652
|
+
reconnectAttempts = 0;
|
|
653
|
+
updateConnectionStatus(true);
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
ws.onmessage = (event) => {
|
|
657
|
+
try {
|
|
658
|
+
const message = JSON.parse(event.data);
|
|
659
|
+
handleWebSocketMessage(message);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
console.error("[Clawguard] Failed to parse message:", err);
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
ws.onclose = () => {
|
|
666
|
+
console.log("[Clawguard] Connection closed, will reconnect...");
|
|
667
|
+
updateConnectionStatus(false);
|
|
668
|
+
scheduleReconnect();
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
ws.onerror = (err) => {
|
|
672
|
+
console.error("[Clawguard] WebSocket error:", err);
|
|
673
|
+
updateConnectionStatus(false);
|
|
674
|
+
};
|
|
675
|
+
} catch (err) {
|
|
676
|
+
console.error("[Clawguard] Failed to connect:", err);
|
|
677
|
+
scheduleReconnect();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function scheduleReconnect() {
|
|
682
|
+
reconnectAttempts++;
|
|
683
|
+
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts));
|
|
684
|
+
setTimeout(connectWebSocket, delay);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function updateConnectionStatus(connected) {
|
|
688
|
+
const statusEl = document.getElementById("clawguard-status");
|
|
689
|
+
if (statusEl) {
|
|
690
|
+
if (connected) {
|
|
691
|
+
statusEl.textContent = "● Live";
|
|
692
|
+
statusEl.className = "bot-status online";
|
|
693
|
+
} else {
|
|
694
|
+
statusEl.textContent = "○ Connecting...";
|
|
695
|
+
statusEl.className = "bot-status offline";
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function handleWebSocketMessage(message) {
|
|
701
|
+
switch (message.type) {
|
|
702
|
+
case "connected":
|
|
703
|
+
console.log("[Clawguard] Server says:", message.message);
|
|
704
|
+
break;
|
|
705
|
+
|
|
706
|
+
case "metrics":
|
|
707
|
+
updateDashboard(message.data);
|
|
708
|
+
break;
|
|
709
|
+
|
|
710
|
+
case "toolEvent":
|
|
711
|
+
handleToolEvent(message.data);
|
|
712
|
+
break;
|
|
713
|
+
|
|
714
|
+
case "toolComplete":
|
|
715
|
+
handleToolComplete(message.data);
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function handleToolEvent(data) {
|
|
721
|
+
const activityList = document.getElementById("activity-list");
|
|
722
|
+
if (!activityList) return;
|
|
723
|
+
|
|
724
|
+
// Clear "empty" message if present
|
|
725
|
+
const emptyMsg = activityList.querySelector(".activity-empty");
|
|
726
|
+
if (emptyMsg) emptyMsg.remove();
|
|
727
|
+
|
|
728
|
+
const actionEvent = data.actionEvent;
|
|
729
|
+
const isBlocked = actionEvent && actionEvent.decision === "blocked";
|
|
730
|
+
const isDanger = data.analysis && data.analysis.danger && data.analysis.danger.dangerous;
|
|
731
|
+
const isInjection = data.analysis && data.analysis.injection && data.analysis.injection.detected;
|
|
732
|
+
|
|
733
|
+
let icon = "✓";
|
|
734
|
+
let className = "approved";
|
|
735
|
+
let text = `Tool executed: ${data.tool}`;
|
|
736
|
+
let context = null;
|
|
737
|
+
|
|
738
|
+
if (isBlocked || isDanger) {
|
|
739
|
+
icon = "⚠️";
|
|
740
|
+
className = "blocked";
|
|
741
|
+
text = `Dangerous action: ${data.tool}`;
|
|
742
|
+
context = {
|
|
743
|
+
title: isDanger ? data.analysis.danger.reason : "Dangerous Action Detected",
|
|
744
|
+
desc: "This action was flagged as potentially dangerous."
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (isInjection) {
|
|
749
|
+
icon = "🛡️";
|
|
750
|
+
className = "blocked attack";
|
|
751
|
+
text = `Caught attack in ${data.tool}`;
|
|
752
|
+
context = {
|
|
753
|
+
title: "Prompt Injection Detected!",
|
|
754
|
+
desc: data.analysis.injection.reason
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Send browser notification for critical events
|
|
759
|
+
if (isInjection) {
|
|
760
|
+
showNotification("🛡️ Prompt Injection Detected!", `Attack attempt caught in ${data.tool}`, true);
|
|
761
|
+
} else if (isDanger) {
|
|
762
|
+
showNotification("⚠️ Dangerous Command Detected", `${data.tool}: ${data.analysis.danger.reason}`, true);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const eventId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
766
|
+
const showFeedback = isBlocked || isDanger || isInjection;
|
|
767
|
+
const feedbackHtml = showFeedback ? `
|
|
768
|
+
<div class="activity-feedback" data-event-id="${eventId}">
|
|
769
|
+
<span class="feedback-label">Was this flag helpful?</span>
|
|
770
|
+
<button class="feedback-btn helpful" data-helpful="true" data-testid="feedback-helpful-${eventId}">👍</button>
|
|
771
|
+
<button class="feedback-btn not-helpful" data-helpful="false" data-testid="feedback-not-helpful-${eventId}">👎</button>
|
|
772
|
+
</div>
|
|
773
|
+
` : "";
|
|
774
|
+
|
|
775
|
+
const itemHtml = context ? `
|
|
776
|
+
<div class="activity-item has-context" data-new="true" data-event-id="${eventId}">
|
|
777
|
+
<div class="activity-header">
|
|
778
|
+
<div class="activity-icon ${className}">${icon}</div>
|
|
779
|
+
<div class="activity-content">
|
|
780
|
+
<div class="activity-text">${text}</div>
|
|
781
|
+
<div class="activity-time">Just now</div>
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
<div class="activity-context ${isInjection ? 'attack' : ''}">
|
|
785
|
+
<div class="activity-context-title">${context.title}</div>
|
|
786
|
+
<div class="activity-context-desc">${context.desc}</div>
|
|
787
|
+
</div>
|
|
788
|
+
${feedbackHtml}
|
|
789
|
+
</div>
|
|
790
|
+
` : `
|
|
791
|
+
<div class="activity-item" data-new="true" data-event-id="${eventId}">
|
|
792
|
+
<div class="activity-icon ${className}">${icon}</div>
|
|
793
|
+
<div class="activity-content">
|
|
794
|
+
<div class="activity-text">${text}</div>
|
|
795
|
+
<div class="activity-time">Just now</div>
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
`;
|
|
799
|
+
|
|
800
|
+
activityList.insertAdjacentHTML("afterbegin", itemHtml);
|
|
801
|
+
|
|
802
|
+
// Attach feedback button handlers
|
|
803
|
+
const newItemEl = activityList.querySelector(`[data-event-id="${eventId}"]`);
|
|
804
|
+
if (newItemEl) {
|
|
805
|
+
newItemEl.querySelectorAll(".feedback-btn").forEach(btn => {
|
|
806
|
+
btn.addEventListener("click", () => {
|
|
807
|
+
const helpful = btn.dataset.helpful === "true";
|
|
808
|
+
saveFeedback(eventId, helpful);
|
|
809
|
+
const feedbackDiv = btn.closest(".activity-feedback");
|
|
810
|
+
if (feedbackDiv) {
|
|
811
|
+
feedbackDiv.innerHTML = `<span class="feedback-thanks">Thanks for your feedback!</span>`;
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Update stats
|
|
818
|
+
const statApproved = document.getElementById("stat-approved");
|
|
819
|
+
const statBlocked = document.getElementById("stat-blocked");
|
|
820
|
+
|
|
821
|
+
if (isBlocked && statBlocked) {
|
|
822
|
+
statBlocked.textContent = parseInt(statBlocked.textContent || 0) + 1;
|
|
823
|
+
} else if (statApproved) {
|
|
824
|
+
statApproved.textContent = parseInt(statApproved.textContent || 0) + 1;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Keep only last 10 items
|
|
828
|
+
const items = activityList.querySelectorAll(".activity-item");
|
|
829
|
+
if (items.length > 10) {
|
|
830
|
+
items[items.length - 1].remove();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Flash new item
|
|
834
|
+
const newItem = activityList.querySelector('[data-new="true"]');
|
|
835
|
+
if (newItem) {
|
|
836
|
+
newItem.style.animation = "fadeIn 0.3s ease-out";
|
|
837
|
+
newItem.removeAttribute("data-new");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function handleToolComplete(data) {
|
|
842
|
+
console.log(`[Clawguard] Tool ${data.tool} completed in ${data.duration}ms`);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Show onboarding unless user already has a saved secret
|
|
846
|
+
if (SECRET) {
|
|
847
|
+
showScreen("dashboard");
|
|
848
|
+
loadDashboard();
|
|
849
|
+
updateAgentsGrid();
|
|
850
|
+
connectWebSocket();
|
|
851
|
+
setInterval(() => {
|
|
852
|
+
loadDashboard();
|
|
853
|
+
updateAgentsGrid();
|
|
854
|
+
}, 30000);
|
|
855
|
+
} else {
|
|
856
|
+
showScreen("onboarding");
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
initTooltips();
|
|
860
|
+
initProtectionToggle();
|
|
861
|
+
initNotificationToggle();
|
|
862
|
+
|
|
863
|
+
function initNotificationToggle() {
|
|
864
|
+
const btn = document.getElementById("btn-notifications");
|
|
865
|
+
if (!btn) return;
|
|
866
|
+
|
|
867
|
+
// Update button state
|
|
868
|
+
function updateButtonState() {
|
|
869
|
+
if (notificationsEnabled && Notification.permission === "granted") {
|
|
870
|
+
btn.classList.add("enabled");
|
|
871
|
+
btn.textContent = "🔔 Notifications On";
|
|
872
|
+
} else {
|
|
873
|
+
btn.classList.remove("enabled");
|
|
874
|
+
btn.textContent = "🔔 Notifications";
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
updateButtonState();
|
|
879
|
+
|
|
880
|
+
btn.addEventListener("click", async () => {
|
|
881
|
+
if (notificationsEnabled) {
|
|
882
|
+
// Disable notifications
|
|
883
|
+
notificationsEnabled = false;
|
|
884
|
+
localStorage.setItem("notifications_enabled", "false");
|
|
885
|
+
updateButtonState();
|
|
886
|
+
} else {
|
|
887
|
+
// Request permission and enable
|
|
888
|
+
const granted = await requestNotificationPermission();
|
|
889
|
+
if (granted) {
|
|
890
|
+
notificationsEnabled = true;
|
|
891
|
+
localStorage.setItem("notifications_enabled", "true");
|
|
892
|
+
showNotification("🦞 Clawguard", "Notifications enabled! You'll be alerted about critical events.", false);
|
|
893
|
+
}
|
|
894
|
+
updateButtonState();
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
})();
|