@contextfort-ai/openclaw-secure 0.1.9 → 0.1.12

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.
@@ -48,7 +48,7 @@
48
48
  .card-sub { font-size: 12px; color: #a1a1aa; margin-top: 4px; }
49
49
 
50
50
  /* Guard summary cards on home */
51
- .guard-cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
51
+ .guard-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
52
52
  .guard-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 16px; background: rgba(255,255,255,0.02); cursor: pointer; transition: all 0.15s; }
53
53
  .guard-card:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.2); }
54
54
  .guard-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
@@ -75,7 +75,7 @@
75
75
  th { text-align: left; padding: 10px 16px; color: #a1a1aa; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.02); }
76
76
  td { padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; }
77
77
  tr:hover td { background: rgba(255,255,255,0.02); }
78
- .cmd { font-family: ui-monospace, monospace; font-size: 12px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
78
+ .cmd { font-family: ui-monospace, monospace; font-size: 12px; max-width: 400px; white-space: pre-wrap; word-break: break-all; }
79
79
 
80
80
  /* Badges */
81
81
  .badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 500; }
@@ -148,12 +148,16 @@
148
148
  <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
149
149
  Home
150
150
  </button>
151
+ <button class="sidebar-btn" data-page="scan" onclick="switchPage('scan')">
152
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
153
+ Secret Scanner
154
+ </button>
151
155
 
152
156
  <div class="sidebar-label">Guards</div>
153
157
 
154
158
  <button class="sidebar-btn" data-page="skill" onclick="switchPage('skill')">
155
159
  <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
156
- Skill Scanner
160
+ Skill &amp; Plugin Scanner
157
161
  <span class="dot dot-green" id="sb-skill-dot" style="margin-left:auto"></span>
158
162
  </button>
159
163
  <button class="sidebar-btn" data-page="bash" onclick="switchPage('bash')">
@@ -173,9 +177,14 @@
173
177
  </button>
174
178
  <button class="sidebar-btn" data-page="exfil" onclick="switchPage('exfil')">
175
179
  <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
176
- Exfil Monitor
180
+ Secrets Exfil Monitor
177
181
  <span class="dot dot-green" id="sb-exfil-dot" style="margin-left:auto"></span>
178
182
  </button>
183
+ <button class="sidebar-btn" data-page="sandbox" onclick="switchPage('sandbox')">
184
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="2"/><path d="M12 2v20"/><path d="M2 12h20"/></svg>
185
+ Plugin Sandbox
186
+ <span class="dot dot-green" id="sb-sandbox-dot" style="margin-left:auto"></span>
187
+ </button>
179
188
  </div>
180
189
  </aside>
181
190
 
@@ -196,11 +205,42 @@
196
205
  <div class="card"><div class="card-label">Active Since</div><div class="card-value" id="ov-since" style="font-size:16px">-</div><div class="card-sub">first hook loaded</div></div>
197
206
  </div>
198
207
 
208
+ <!-- Active Block Banner (shown when agent is blocked) -->
209
+ <div id="home-block-banner" style="display:none;margin-bottom:24px;border:1px solid rgba(239,68,68,0.3);border-radius:8px;padding:16px 20px;background:rgba(239,68,68,0.08)">
210
+ <div style="display:flex;align-items:flex-start;gap:12px">
211
+ <svg style="width:20px;height:20px;flex-shrink:0;margin-top:2px" fill="none" stroke="#f87171" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
212
+ <div style="flex:1">
213
+ <div style="font-size:15px;font-weight:600;color:#f87171;margin-bottom:4px" id="home-block-title">Agent Blocked</div>
214
+ <div style="font-size:13px;color:#a1a1aa;margin-bottom:12px" id="home-block-reason"></div>
215
+ <div style="display:flex;gap:8px">
216
+ <button class="btn btn-sm" id="home-block-view-btn" style="color:#f87171;border-color:rgba(239,68,68,0.3)" onclick="">View Details</button>
217
+ <button class="btn btn-sm" id="home-block-remove-btn" style="color:#4ade80;border-color:rgba(34,197,94,0.3)" onclick="removeBlock()">Remove Block &amp; Allow OpenClaw</button>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+
223
+ <!-- Anthropic API Key box (shown when not set) -->
224
+ <div id="anthropic-key-box" style="display:none;margin-bottom:24px;border:1px solid rgba(250,204,21,0.3);border-radius:8px;padding:16px 20px;background:rgba(250,204,21,0.06)">
225
+ <div style="display:flex;align-items:flex-start;gap:12px">
226
+ <svg style="width:20px;height:20px;flex-shrink:0;margin-top:2px" fill="none" stroke="#facc15" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
227
+ <div style="flex:1">
228
+ <div style="font-size:15px;font-weight:600;color:#facc15;margin-bottom:4px">Prompt Injection Detection Inactive</div>
229
+ <div style="font-size:13px;color:#a1a1aa;margin-bottom:12px">An Anthropic API key is required for Haiku to scan command output for prompt injection. Enter your key below or set <code style="background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:4px;font-size:12px">ANTHROPIC_API_KEY</code> in your shell.</div>
230
+ <div style="display:flex;gap:8px;align-items:center">
231
+ <input type="password" id="anthropic-key-input" placeholder="sk-ant-..." style="flex:1;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:8px 12px;color:#e4e4e7;font-size:13px;font-family:ui-monospace,monospace;outline:none" />
232
+ <button class="btn btn-sm" id="anthropic-key-save-btn" style="color:#facc15;border-color:rgba(250,204,21,0.3);white-space:nowrap" onclick="saveAnthropicKey()">Save Key</button>
233
+ </div>
234
+ <div id="anthropic-key-msg" style="font-size:12px;margin-top:8px;display:none"></div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+
199
239
  <div class="guard-cards">
200
240
  <div class="guard-card" onclick="switchPage('skill')">
201
241
  <div class="guard-card-header">
202
242
  <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
203
- <span>Skill Scanner</span>
243
+ <span>Skill &amp; Plugin Scanner</span>
204
244
  <span class="dot dot-green" id="g-skill-dot" style="margin-left:auto"></span>
205
245
  </div>
206
246
  <div class="guard-stat"><b id="g-skill-blocks">0</b> blocks</div>
@@ -232,26 +272,127 @@
232
272
  <div class="guard-card" onclick="switchPage('exfil')">
233
273
  <div class="guard-card-header">
234
274
  <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
235
- <span>Exfil Monitor</span>
275
+ <span>Secrets Exfil Monitor</span>
236
276
  <span class="dot dot-green" id="g-exfil-dot" style="margin-left:auto"></span>
237
277
  </div>
238
278
  <div class="guard-stat"><b id="g-exfil-detections">0</b> detections</div>
239
279
  </div>
280
+ <div class="guard-card" onclick="switchPage('sandbox')">
281
+ <div class="guard-card-header">
282
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="2"/><path d="M12 2v20"/><path d="M2 12h20"/></svg>
283
+ <span>Plugin Sandbox</span>
284
+ <span class="dot dot-green" id="g-sandbox-dot" style="margin-left:auto"></span>
285
+ </div>
286
+ <div class="guard-stat"><b id="g-sandbox-scrubs">0</b> env scrubs &middot; <b id="g-sandbox-fs">0</b> FS blocks &middot; <b id="g-sandbox-net">0</b> net logs</div>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- Secret Scanner Summary -->
291
+ <div style="margin-top:24px">
292
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
293
+ <h2 style="font-size:16px;font-weight:600">Secret Scanner</h2>
294
+ <button class="btn btn-sm" onclick="switchPage('scan')">View Details</button>
295
+ </div>
296
+ <div class="card" id="home-scan-card">
297
+ <div style="display:flex;align-items:center;gap:12px">
298
+ <svg style="width:20px;height:20px;flex-shrink:0" fill="none" stroke="#a1a1aa" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
299
+ <div>
300
+ <div style="font-size:14px" id="home-scan-status">Loading...</div>
301
+ <div style="font-size:12px;color:#a1a1aa;margin-top:2px" id="home-scan-detail"></div>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ <!-- SECRET SCANNER PAGE -->
309
+ <div id="page-scan" style="display:none">
310
+ <div class="page-header">
311
+ <h1>Secret Scanner</h1>
312
+ <p>Scan directories accessible to OpenClaw for hardcoded secrets using TruffleHog.</p>
313
+ </div>
314
+
315
+ <div class="cards" style="margin-bottom:24px">
316
+ <div class="card"><div class="card-label">TruffleHog</div><div class="card-value" id="scan-thog-status" style="font-size:16px">Checking...</div></div>
317
+ <div class="card"><div class="card-label">Last Scan</div><div class="card-value" id="scan-last-time" style="font-size:16px">Never</div></div>
318
+ <div class="card"><div class="card-label">LIVE Keys</div><div class="card-value" id="scan-verified" style="color:#f87171">-</div></div>
319
+ <div class="card"><div class="card-label">Total Findings</div><div class="card-value" id="scan-total">-</div></div>
320
+ </div>
321
+
322
+ <div style="display:flex;gap:12px;align-items:center;margin-bottom:24px">
323
+ <button class="btn" id="scan-run-btn" onclick="runScan()">
324
+ <svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
325
+ Run Scan
326
+ </button>
327
+ <button class="btn" id="scan-solve-btn" onclick="runSolve()" style="display:none">
328
+ <svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
329
+ Scrub Selected
330
+ </button>
331
+ </div>
332
+
333
+ <!-- Scan targets -->
334
+ <div id="scan-targets-section" style="display:none;margin-bottom:24px">
335
+ <h3 style="font-size:14px;font-weight:600;margin-bottom:8px">Scanned Directories</h3>
336
+ <div id="scan-targets-list"></div>
337
+ </div>
338
+
339
+ <!-- Findings table -->
340
+ <div class="table-wrap">
341
+ <div class="table-header">
342
+ <h3>Findings</h3>
343
+ <div class="filter-row">
344
+ <label class="check-label" id="scan-select-all-wrap" style="display:none"><input type="checkbox" id="scan-select-all" onchange="toggleScanSelectAll()"> Select all</label>
345
+ </div>
346
+ </div>
347
+ <div class="table-body" id="scan-findings-body">
348
+ <div class="empty">No scan results yet. Click "Run Scan" to start.</div>
349
+ </div>
350
+ </div>
351
+
352
+ <!-- Solve results -->
353
+ <div id="scan-solve-results" style="display:none;margin-top:24px">
354
+ <div class="table-wrap">
355
+ <div class="table-header"><h3>Scrub Results</h3></div>
356
+ <div class="table-body" id="scan-solve-body"></div>
357
+ </div>
240
358
  </div>
241
359
  </div>
242
360
 
243
361
  <!-- SKILL SCANNER PAGE -->
244
362
  <div id="page-skill" style="display:none">
245
363
  <div class="page-header">
246
- <h1>Skill Scanner</h1>
247
- <p>Skills cross-indexed and scanned for prompt injection patterns via Haiku.</p>
364
+ <h1>Skill &amp; Plugin Scanner</h1>
365
+ <p>Skills and plugin code cross-indexed and scanned for prompt injection patterns via Haiku.</p>
366
+ </div>
367
+
368
+ <!-- Skills Status (top section) -->
369
+ <div class="table-wrap" style="margin-bottom:24px">
370
+ <div class="table-header">
371
+ <h3>Skills Overview</h3>
372
+ </div>
373
+ <div class="table-body" id="skill-status-body">
374
+ <div class="empty">Loading skills...</div>
375
+ </div>
376
+ </div>
377
+
378
+ <!-- File Viewer Modal -->
379
+ <div id="skill-file-modal" style="display:none;position:fixed;inset:0;z-index:50;background:rgba(0,0,0,0.7);display:none;align-items:center;justify-content:center;padding:24px">
380
+ <div style="background:#18181b;border:1px solid rgba(255,255,255,0.1);border-radius:8px;width:100%;max-width:720px;max-height:80vh;display:flex;flex-direction:column">
381
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,0.1)">
382
+ <span style="font-size:14px;font-weight:600" id="skill-modal-title">Files</span>
383
+ <button class="btn btn-sm" onclick="closeSkillModal()">&times;</button>
384
+ </div>
385
+ <div id="skill-modal-content" style="overflow:auto;padding:16px;flex:1"></div>
386
+ </div>
248
387
  </div>
388
+
389
+ <!-- Activity Log (bottom section) -->
249
390
  <div class="table-wrap">
250
391
  <div class="table-header">
251
- <h3>Events</h3>
392
+ <h3>Activity Log</h3>
252
393
  <div class="filter-row">
253
- <label class="check-label"><input type="checkbox" id="skill-blocked-only" onchange="renderGuardPage('skill')"> Only show blocked</label>
254
- <label class="check-label"><input type="checkbox" id="skill-server-only" onchange="renderGuardPage('skill')"> Server sent events</label>
394
+ <label class="check-label"><input type="checkbox" id="skill-blocked-only" onchange="renderSkillPage()"> Only flagged/blocked</label>
395
+ <label class="check-label"><input type="checkbox" id="skill-server-only" onchange="renderSkillPage()"> Server sent events</label>
255
396
  </div>
256
397
  </div>
257
398
  <div class="table-body" id="skill-table-body"></div>
@@ -268,7 +409,8 @@
268
409
  <div class="table-header">
269
410
  <h3>Events</h3>
270
411
  <div class="filter-row">
271
- <label class="check-label"><input type="checkbox" id="bash-blocked-only" onchange="renderGuardPage('bash')"> Only show blocked</label>
412
+ <label class="check-label"><input type="checkbox" id="bash-blocked-only" onchange="renderGuardPage('bash')"> Show only blocked</label>
413
+ <label class="check-label"><input type="checkbox" id="bash-show-system" onchange="renderGuardPage('bash')"> Show system commands</label>
272
414
  <label class="check-label"><input type="checkbox" id="bash-server-only" onchange="renderGuardPage('bash')"> Server sent events</label>
273
415
  </div>
274
416
  </div>
@@ -284,10 +426,10 @@
284
426
  </div>
285
427
  <div class="table-wrap">
286
428
  <div class="table-header">
287
- <h3>Events</h3>
429
+ <h3>Scan Events</h3>
288
430
  <div class="filter-row">
289
- <label class="check-label"><input type="checkbox" id="pi-blocked-only" onchange="renderGuardPage('pi')"> Only show blocked</label>
290
- <label class="check-label"><input type="checkbox" id="pi-server-only" onchange="renderGuardPage('pi')"> Server sent events</label>
431
+ <label class="check-label"><input type="checkbox" id="pi-blocked-only" onchange="renderPiPage()"> Only flagged</label>
432
+ <label class="check-label"><input type="checkbox" id="pi-server-only" onchange="renderPiPage()"> Server sent events</label>
291
433
  </div>
292
434
  </div>
293
435
  <div class="table-body" id="pi-table-body"></div>
@@ -304,8 +446,8 @@
304
446
  <div class="table-header">
305
447
  <h3>Events</h3>
306
448
  <div class="filter-row">
307
- <label class="check-label"><input type="checkbox" id="secrets-blocked-only" onchange="renderGuardPage('secrets')"> Only show blocked</label>
308
- <label class="check-label"><input type="checkbox" id="secrets-server-only" onchange="renderGuardPage('secrets')"> Server sent events</label>
449
+ <label class="check-label"><input type="checkbox" id="secrets-blocked-only" onchange="renderSecretsPage()"> Only show blocked</label>
450
+ <label class="check-label"><input type="checkbox" id="secrets-server-only" onchange="renderSecretsPage()"> Server sent events</label>
309
451
  </div>
310
452
  </div>
311
453
  <div class="table-body" id="secrets-table-body"></div>
@@ -315,14 +457,35 @@
315
457
  <!-- EXFIL MONITOR PAGE -->
316
458
  <div id="page-exfil" style="display:none">
317
459
  <div class="page-header">
318
- <h1>Exfil Monitor</h1>
460
+ <h1>Secrets Exfil Monitor</h1>
319
461
  <p>Detects when sensitive environment variables are transmitted to external servers via curl, wget, or nc.</p>
320
462
  </div>
463
+
464
+ <!-- Destination Allowlist -->
465
+ <div class="card" style="margin-bottom:24px;padding:20px">
466
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
467
+ <div>
468
+ <h3 style="font-size:15px;font-weight:600;margin-bottom:4px">Destination Allowlist</h3>
469
+ <div style="font-size:12px;color:#a1a1aa">When enabled, commands sending secrets to domains NOT in this list will be blocked.</div>
470
+ </div>
471
+ <div style="display:flex;gap:8px;align-items:center">
472
+ <span id="exfil-al-status-badge"></span>
473
+ <button class="btn btn-sm" id="exfil-al-toggle-btn" onclick="toggleExfilAllowlist()">Enable</button>
474
+ </div>
475
+ </div>
476
+ <div id="exfil-al-domains" style="margin-bottom:12px"></div>
477
+ <div style="display:flex;gap:8px;align-items:center">
478
+ <input type="text" id="exfil-al-input" placeholder="e.g. api.notion.com or *.supabase.co" style="flex:1;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:8px 12px;color:#e4e4e7;font-size:13px;font-family:ui-monospace,monospace;outline:none" />
479
+ <button class="btn btn-sm" onclick="addExfilDomain()">Add</button>
480
+ </div>
481
+ </div>
482
+
321
483
  <div class="table-wrap">
322
484
  <div class="table-header">
323
485
  <h3>Events</h3>
324
486
  <div class="filter-row">
325
487
  <label class="check-label"><input type="checkbox" id="exfil-blocked-only" onchange="renderGuardPage('exfil')"> Only show blocked</label>
488
+ <label class="check-label"><input type="checkbox" id="exfil-show-passed" onchange="renderGuardPage('exfil')"> Show passed</label>
326
489
  <label class="check-label"><input type="checkbox" id="exfil-server-only" onchange="renderGuardPage('exfil')"> Server sent events</label>
327
490
  </div>
328
491
  </div>
@@ -330,6 +493,36 @@
330
493
  </div>
331
494
  </div>
332
495
 
496
+ <!-- PLUGIN SANDBOX PAGE -->
497
+ <div id="page-sandbox" style="display:none">
498
+ <div class="page-header">
499
+ <h1>Plugin Sandbox</h1>
500
+ <p>Runtime isolation for MCP plugin processes: env scrubbing, FS blocklist, and network logging.</p>
501
+ </div>
502
+
503
+ <div class="cards" style="margin-bottom:24px">
504
+ <div class="card"><div class="card-label">Env Scrubs</div><div class="card-value" id="sandbox-scrubs-count" style="color:#60a5fa">0</div><div class="card-sub">plugin spawns with env stripped</div></div>
505
+ <div class="card"><div class="card-label">FS Blocks</div><div class="card-value" id="sandbox-fs-count" style="color:#f87171">0</div><div class="card-sub">blocked reads to sensitive dirs</div></div>
506
+ <div class="card"><div class="card-label">Network Requests</div><div class="card-value" id="sandbox-net-count">0</div><div class="card-sub">outbound connections logged</div></div>
507
+ <div class="card"><div class="card-label">Sandbox Active</div><div class="card-value" id="sandbox-active-status" style="font-size:16px;color:#4ade80">Ready</div><div class="card-sub">activates on plugin spawn</div></div>
508
+ </div>
509
+
510
+ <div class="table-wrap">
511
+ <div class="table-header">
512
+ <h3>Events</h3>
513
+ <div class="filter-row">
514
+ <select id="sandbox-filter" onchange="renderSandboxPage()">
515
+ <option value="all">All events</option>
516
+ <option value="env_scrubbed">Env Scrubs</option>
517
+ <option value="fs_blocked">FS Blocks</option>
518
+ <option value="network_logged">Network Requests</option>
519
+ </select>
520
+ </div>
521
+ </div>
522
+ <div class="table-body" id="sandbox-table-body"></div>
523
+ </div>
524
+ </div>
525
+
333
526
  </main>
334
527
  </div>
335
528
 
@@ -340,14 +533,23 @@ let localEvents = [];
340
533
  let serverEvents = [];
341
534
  let overviewData = {};
342
535
 
343
- // Guard blocker name mapping
344
- const GUARD_BLOCKER = { skill: 'skill', bash: 'tirith', pi: 'prompt_injection', secrets: 'env_var' };
536
+ // Map tab name → guard field value in log events
537
+ const GUARD_KEY = { skill: 'skill', bash: 'tirith', pi: 'prompt_injection', secrets: 'env_var', exfil: 'exfil' };
538
+ // All events for this guard (passed + blocked + special)
345
539
  const GUARD_EVENTS = {
346
- skill: e => e.blocker === 'skill',
347
- bash: e => e.blocker === 'tirith',
348
- pi: e => e.blocker === 'prompt_injection',
349
- secrets: e => e.blocker === 'env_var' || e.event === 'output_redacted',
350
- exfil: e => e.event === 'exfil_attempt',
540
+ skill: e => e.guard === 'skill',
541
+ bash: e => e.guard === 'tirith',
542
+ pi: e => e.guard === 'prompt_injection',
543
+ secrets: e => e.guard === 'env_var' || e.event === 'output_redacted',
544
+ exfil: e => e.guard === 'exfil',
545
+ };
546
+ // Only blocked/flagged/detected events for this guard
547
+ const GUARD_BLOCKED = {
548
+ skill: e => e.guard === 'skill' && (e.decision === 'block' || e.decision === 'scan_flagged' || e.decision === 'binary_detected'),
549
+ bash: e => e.guard === 'tirith' && e.decision === 'block',
550
+ pi: e => e.guard === 'prompt_injection' && (e.decision === 'block' || e.decision === 'scan_flagged'),
551
+ secrets: e => (e.guard === 'env_var' && e.decision === 'block') || e.event === 'output_redacted',
552
+ exfil: e => e.guard === 'exfil',
351
553
  };
352
554
  const SERVER_GUARD = {
353
555
  skill: e => e.destination === 'supabase' || (e.destination === 'posthog' && e.event && e.event.includes('skill')),
@@ -366,10 +568,16 @@ function setDays(d) {
366
568
  function switchPage(page) {
367
569
  currentPage = page;
368
570
  document.querySelectorAll('.sidebar-btn').forEach(b => b.classList.toggle('active', b.dataset.page === page));
369
- ['home','skill','bash','pi','secrets','exfil'].forEach(p => {
571
+ ['home','scan','skill','bash','pi','secrets','exfil','sandbox'].forEach(p => {
370
572
  document.getElementById('page-' + p).style.display = p === page ? '' : 'none';
371
573
  });
372
- if (page !== 'home') renderGuardPage(page);
574
+ if (page === 'scan') loadScanStatus();
575
+ else if (page === 'skill') renderSkillPage();
576
+ else if (page === 'pi') renderPiPage();
577
+ else if (page === 'secrets') renderSecretsPage();
578
+ else if (page === 'exfil') { loadExfilAllowlist(); renderGuardPage('exfil'); }
579
+ else if (page === 'sandbox') renderSandboxPage();
580
+ else if (page !== 'home') renderGuardPage(page);
373
581
  }
374
582
 
375
583
  async function refresh() {
@@ -384,7 +592,16 @@ async function refresh() {
384
592
  overviewData = ov;
385
593
  renderOverview(ov);
386
594
  renderSidebarDots(ov);
387
- if (currentPage !== 'home') renderGuardPage(currentPage);
595
+ renderBlockBanner();
596
+ checkAnthropicKey();
597
+ if (currentPage === 'scan') loadScanStatus();
598
+ else if (currentPage === 'skill') renderSkillPage();
599
+ else if (currentPage === 'pi') renderPiPage();
600
+ else if (currentPage === 'secrets') renderSecretsPage();
601
+ else if (currentPage === 'exfil') { loadExfilAllowlist(); renderGuardPage('exfil'); }
602
+ else if (currentPage === 'sandbox') renderSandboxPage();
603
+ else if (currentPage !== 'home') renderGuardPage(currentPage);
604
+ loadScanStatus(); // always update home scan summary
388
605
  document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
389
606
  } catch (e) {
390
607
  console.error('Refresh failed:', e);
@@ -405,6 +622,9 @@ function renderOverview(ov) {
405
622
  document.getElementById('g-sec-blocks').textContent = gs.secrets_guard?.blocks || 0;
406
623
  document.getElementById('g-sec-redactions').textContent = gs.secrets_guard?.redactions || 0;
407
624
  document.getElementById('g-exfil-detections').textContent = gs.exfil_monitor?.detections || 0;
625
+ document.getElementById('g-sandbox-scrubs').textContent = gs.plugin_sandbox?.scrubs || 0;
626
+ document.getElementById('g-sandbox-fs').textContent = gs.plugin_sandbox?.fs_blocks || 0;
627
+ document.getElementById('g-sandbox-net').textContent = gs.plugin_sandbox?.net_logs || 0;
408
628
 
409
629
  const hasData = ov.total > 0;
410
630
  setDot('g-skill-dot', hasData, gs.skill_scanner?.blocks > 0);
@@ -412,6 +632,7 @@ function renderOverview(ov) {
412
632
  setDot('g-pi-dot', hasData, gs.prompt_injection?.blocks > 0);
413
633
  setDot('g-sec-dot', hasData, (gs.secrets_guard?.blocks > 0) || (gs.secrets_guard?.redactions > 0));
414
634
  setDot('g-exfil-dot', hasData, gs.exfil_monitor?.detections > 0, 'dot-yellow');
635
+ setDot('g-sandbox-dot', hasData, (gs.plugin_sandbox?.fs_blocks > 0), 'dot-yellow');
415
636
  }
416
637
 
417
638
  function renderSidebarDots(ov) {
@@ -422,6 +643,7 @@ function renderSidebarDots(ov) {
422
643
  setDot('sb-pi-dot', hasData, gs.prompt_injection?.blocks > 0);
423
644
  setDot('sb-sec-dot', hasData, (gs.secrets_guard?.blocks > 0) || (gs.secrets_guard?.redactions > 0));
424
645
  setDot('sb-exfil-dot', hasData, gs.exfil_monitor?.detections > 0, 'dot-yellow');
646
+ setDot('sb-sandbox-dot', hasData, (gs.plugin_sandbox?.fs_blocks > 0), 'dot-yellow');
425
647
  }
426
648
 
427
649
  function setDot(id, hasData, hasBlocks, activeClass) {
@@ -430,13 +652,639 @@ function setDot(id, hasData, hasBlocks, activeClass) {
430
652
  el.className = 'dot ' + (hasData ? (hasBlocks ? (activeClass || 'dot-red') : 'dot-green') : 'dot-gray');
431
653
  }
432
654
 
655
+ // =============================================
656
+ // Home — Active Block Banner
657
+ // =============================================
658
+ function renderBlockBanner() {
659
+ const banner = document.getElementById('home-block-banner');
660
+ if (!banner) return;
661
+
662
+ // Find the most recent block event (scan_flagged from skill or prompt_injection, or binary_detected)
663
+ const blockEvent = localEvents.find(e =>
664
+ e.event === 'guard_check' &&
665
+ ((e.guard === 'skill' && (e.decision === 'scan_flagged' || e.decision === 'binary_detected')) ||
666
+ (e.guard === 'prompt_injection' && e.decision === 'scan_flagged'))
667
+ );
668
+
669
+ // Check if block was already removed
670
+ const unblockEvent = localEvents.find(e => e.event === 'block_removed');
671
+ if (unblockEvent && blockEvent && new Date(unblockEvent.ts) > new Date(blockEvent.ts)) {
672
+ banner.style.display = 'none';
673
+ return;
674
+ }
675
+
676
+ if (!blockEvent) {
677
+ banner.style.display = 'none';
678
+ return;
679
+ }
680
+
681
+ banner.style.display = '';
682
+ const guard = blockEvent.guard === 'skill' ? 'Skill & Plugin Scanner' : 'Prompt Injection';
683
+ const guardPage = blockEvent.guard === 'skill' ? 'skill' : 'pi';
684
+ document.getElementById('home-block-title').textContent = `Agent Blocked — ${guard}`;
685
+ document.getElementById('home-block-reason').textContent = blockEvent.reason || 'A security threat was detected. All commands are blocked.';
686
+ document.getElementById('home-block-view-btn').setAttribute('onclick', `switchPage('${guardPage}')`);
687
+ }
688
+
689
+ async function removeBlock() {
690
+ if (!confirm('Remove the active block and allow OpenClaw to proceed?\n\nOnly do this if you have reviewed and resolved the security issue.')) return;
691
+ try {
692
+ const res = await fetch('/api/unblock', { method: 'POST' });
693
+ const data = await res.json();
694
+ if (data.error) { alert('Error: ' + data.error); return; }
695
+ document.getElementById('home-block-banner').style.display = 'none';
696
+ refresh();
697
+ } catch (e) {
698
+ alert('Failed to remove block: ' + e.message);
699
+ }
700
+ }
701
+
702
+ // =============================================
703
+ // Anthropic API Key Box
704
+ // =============================================
705
+ async function checkAnthropicKey() {
706
+ try {
707
+ const res = await fetch('/api/anthropic-key');
708
+ const data = await res.json();
709
+ const box = document.getElementById('anthropic-key-box');
710
+ if (!data.hasKey) {
711
+ box.style.display = '';
712
+ } else {
713
+ box.style.display = 'none';
714
+ }
715
+ } catch {}
716
+ }
717
+
718
+ async function saveAnthropicKey() {
719
+ const input = document.getElementById('anthropic-key-input');
720
+ const msg = document.getElementById('anthropic-key-msg');
721
+ const key = input.value.trim();
722
+ if (!key) return;
723
+ try {
724
+ const res = await fetch('/api/anthropic-key', {
725
+ method: 'POST',
726
+ headers: { 'Content-Type': 'application/json' },
727
+ body: JSON.stringify({ key }),
728
+ });
729
+ const data = await res.json();
730
+ msg.style.display = '';
731
+ if (data.error) {
732
+ msg.style.color = '#f87171';
733
+ msg.textContent = data.error;
734
+ } else {
735
+ msg.style.color = '#4ade80';
736
+ msg.textContent = 'Key saved. Restart openclaw for prompt injection detection to activate.';
737
+ input.value = '';
738
+ setTimeout(() => { document.getElementById('anthropic-key-box').style.display = 'none'; }, 3000);
739
+ }
740
+ } catch (e) {
741
+ msg.style.display = '';
742
+ msg.style.color = '#f87171';
743
+ msg.textContent = 'Failed: ' + e.message;
744
+ }
745
+ }
746
+
747
+ // =============================================
748
+ // Skill Scanner Page
749
+ // =============================================
750
+ function renderSkillPage() {
751
+ renderSkillStatus();
752
+ renderSkillActivityLog();
753
+ }
754
+
755
+ function renderSkillStatus() {
756
+ const container = document.getElementById('skill-status-body');
757
+ // Get skill events that tell us about skills (init_skill, scanning, scan_clean, scan_flagged, binary_detected, removed, modified, deleted)
758
+ const skillEvents = localEvents.filter(e => e.guard === 'skill' && e.decision !== 'allow' && e.decision !== 'init');
759
+
760
+ // Build latest status per skill by path
761
+ const skillMap = new Map();
762
+ // Process in chronological order (events are newest-first, so reverse)
763
+ const chronological = [...skillEvents].reverse();
764
+ for (const e of chronological) {
765
+ const sp = e.detail?.skill_path;
766
+ if (!sp) continue;
767
+ const name = e.detail?.skill_name || sp.split('/').pop();
768
+ if (!skillMap.has(sp)) {
769
+ skillMap.set(sp, { name, path: sp, dir: e.detail?.skill_dir || '', status: 'clean', reason: '', fileCount: 0, fileNames: [], binaryFiles: [], skippedFiles: [], fileContents: [], ts: e.ts, decision: e.decision, type: e.detail?.type || 'skill' });
770
+ }
771
+ const skill = skillMap.get(sp);
772
+ skill.ts = e.ts; // update to latest timestamp
773
+ skill.decision = e.decision;
774
+ if (e.detail?.type) skill.type = e.detail.type;
775
+ if (e.detail?.file_count !== undefined) skill.fileCount = e.detail.file_count;
776
+ if (e.detail?.file_names) skill.fileNames = e.detail.file_names;
777
+ if (e.detail?.binary_files) skill.binaryFiles = e.detail.binary_files;
778
+ if (e.detail?.skipped_files) skill.skippedFiles = e.detail.skipped_files;
779
+ if (e.detail?.file_contents) skill.fileContents = e.detail.file_contents;
780
+ // Determine status
781
+ if (e.decision === 'scan_flagged') { skill.status = 'malicious'; skill.reason = e.reason || ''; }
782
+ else if (e.decision === 'binary_detected') { skill.status = 'binary'; skill.reason = e.reason || ''; }
783
+ else if (e.decision === 'scan_clean') { skill.status = 'clean'; skill.reason = ''; }
784
+ else if (e.decision === 'removed' || e.decision === 'deleted') { skill.status = 'removed'; skill.reason = ''; }
785
+ else if (e.decision === 'modified') { skill.status = 'scanning'; }
786
+ else if (e.decision === 'scanning') { skill.status = 'scanning'; }
787
+ else if (e.decision === 'init_skill') {
788
+ skill.status = e.detail?.status || 'clean';
789
+ skill.reason = e.detail?.flagged_reason || '';
790
+ }
791
+ }
792
+
793
+ const skills = Array.from(skillMap.values()).filter(s => s.status !== 'removed');
794
+
795
+ if (skills.length === 0) {
796
+ container.innerHTML = '<div class="empty">No skills or plugins discovered yet. They appear when openclaw-secure is active.</div>';
797
+ return;
798
+ }
799
+
800
+ container.innerHTML = `<table><thead><tr>
801
+ <th style="width:40px"></th>
802
+ <th>Name</th>
803
+ <th>Type</th>
804
+ <th>Path</th>
805
+ <th>Status</th>
806
+ <th>Reason</th>
807
+ <th style="text-align:center">Files</th>
808
+ <th>Last Scanned</th>
809
+ </tr></thead><tbody>` +
810
+ skills.map((s, i) => {
811
+ const statusBadge = s.status === 'malicious'
812
+ ? '<span class="badge badge-red">Malicious</span>'
813
+ : s.status === 'binary'
814
+ ? '<span class="badge badge-red">Binary</span>'
815
+ : s.status === 'scanning'
816
+ ? '<span class="badge badge-blue">Scanning</span>'
817
+ : '<span class="badge badge-green">Clean</span>';
818
+ const typeBadge = s.type === 'plugin'
819
+ ? '<span class="badge badge-blue">Plugin</span>'
820
+ : '<span class="badge" style="background:rgba(255,255,255,0.06);color:#a1a1aa">Skill</span>';
821
+ const deleteBtn = (s.status === 'malicious' || s.status === 'binary')
822
+ ? `<button class="btn btn-sm" style="color:#f87171;border-color:rgba(239,68,68,0.3)" onclick="deleteSkill('${esc(s.path.replace(/'/g, "\\'"))}')">Delete</button>`
823
+ : '';
824
+ const viewFiles = s.fileNames.length > 0 || s.fileContents.length > 0
825
+ ? `<span class="payload-toggle" onclick="openSkillFiles(${i})">${s.fileCount || s.fileNames.length}</span>`
826
+ : `<span style="color:#a1a1aa">${s.fileCount || 0}</span>`;
827
+ const skippedWarning = s.skippedFiles && s.skippedFiles.length > 0
828
+ ? `<span title="${esc(s.skippedFiles.map(f => f.file + ': ' + f.reason).join('\n'))}" style="color:#eab308;cursor:help;margin-left:4px">&#9888; ${s.skippedFiles.length} skipped</span>`
829
+ : '';
830
+ return `<tr>
831
+ <td>${deleteBtn}</td>
832
+ <td style="font-weight:500;font-family:ui-monospace,monospace;font-size:13px">${esc(s.name)}</td>
833
+ <td>${typeBadge}</td>
834
+ <td style="font-size:12px;color:#a1a1aa;max-width:250px;word-break:break-all">${esc(s.path)}</td>
835
+ <td>${statusBadge}</td>
836
+ <td style="font-size:12px;color:#a1a1aa;max-width:300px">${esc(s.reason)}</td>
837
+ <td style="text-align:center">${viewFiles}${skippedWarning}</td>
838
+ <td style="white-space:nowrap;color:#a1a1aa;font-size:12px">${formatTime(s.ts)}</td>
839
+ </tr>`;
840
+ }).join('') + '</tbody></table>';
841
+
842
+ // Store skills data for file modal
843
+ window._skillStatusData = skills;
844
+ }
845
+
846
+ function openSkillFiles(idx) {
847
+ const skill = window._skillStatusData?.[idx];
848
+ if (!skill) return;
849
+ const modal = document.getElementById('skill-file-modal');
850
+ const title = document.getElementById('skill-modal-title');
851
+ const content = document.getElementById('skill-modal-content');
852
+ title.textContent = 'Files in ' + skill.name;
853
+
854
+ if (skill.fileContents && skill.fileContents.length > 0) {
855
+ content.innerHTML = skill.fileContents.map(f =>
856
+ `<div style="border:1px solid rgba(255,255,255,0.1);border-radius:6px;overflow:hidden;margin-bottom:12px">
857
+ <div style="background:rgba(255,255,255,0.05);padding:6px 12px;font-family:ui-monospace,monospace;font-size:12px;color:#a1a1aa;border-bottom:1px solid rgba(255,255,255,0.1)">${esc(f.path)}</div>
858
+ <pre style="padding:8px 12px;font-size:12px;font-family:ui-monospace,monospace;white-space:pre-wrap;word-break:break-all;max-height:256px;overflow:auto;margin:0">${esc(f.content)}</pre>
859
+ </div>`
860
+ ).join('');
861
+ } else if (skill.fileNames && skill.fileNames.length > 0) {
862
+ content.innerHTML = '<div style="padding:8px 0">' + skill.fileNames.map(f =>
863
+ `<div style="padding:4px 0;font-family:ui-monospace,monospace;font-size:13px;color:#a1a1aa">${esc(f)}</div>`
864
+ ).join('') + '</div>';
865
+ } else {
866
+ content.innerHTML = '<div class="empty">No file data available for this skill.</div>';
867
+ }
868
+
869
+ modal.style.display = 'flex';
870
+ }
871
+
872
+ function closeSkillModal() {
873
+ document.getElementById('skill-file-modal').style.display = 'none';
874
+ }
875
+
876
+ async function deleteSkill(skillPath) {
877
+ if (!confirm('Delete this skill directory permanently?\n\n' + skillPath + '\n\nThis cannot be undone.')) return;
878
+ try {
879
+ const res = await fetch('/api/skill/delete', {
880
+ method: 'POST',
881
+ headers: { 'Content-Type': 'application/json' },
882
+ body: JSON.stringify({ skillPath }),
883
+ });
884
+ const data = await res.json();
885
+ if (data.error) { alert('Error: ' + data.error); return; }
886
+ // Refresh
887
+ refresh();
888
+ } catch (e) {
889
+ alert('Failed to delete skill: ' + e.message);
890
+ }
891
+ }
892
+
893
+ function renderSkillActivityLog() {
894
+ const serverOnly = document.getElementById('skill-server-only')?.checked;
895
+ const blockedOnly = document.getElementById('skill-blocked-only')?.checked;
896
+ const container = document.getElementById('skill-table-body');
897
+
898
+ if (serverOnly) {
899
+ const filtered = serverEvents.filter(SERVER_GUARD.skill || (() => false));
900
+ if (filtered.length === 0) {
901
+ container.innerHTML = '<div class="empty">No server send events for skill scanner.</div>';
902
+ return;
903
+ }
904
+ container.innerHTML = `<table><thead><tr><th>Time</th><th>Destination</th><th>Event</th><th>Payload</th></tr></thead><tbody>` +
905
+ filtered.map((e, i) => {
906
+ const dest = e.destination === 'posthog' ? '<span class="badge badge-blue">PostHog</span>' : '<span class="badge badge-yellow">Supabase</span>';
907
+ const payloadStr = JSON.stringify(e.properties || e.payload || {}, null, 2);
908
+ const uid = 'skill-sv-' + i;
909
+ return `<tr>
910
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
911
+ <td>${dest}</td>
912
+ <td>${esc(e.event || '-')}</td>
913
+ <td><span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">View payload</span><div class="payload-content" id="${uid}">${esc(payloadStr)}</div></td>
914
+ </tr>`;
915
+ }).join('') + '</tbody></table>';
916
+ } else {
917
+ // Show all skill-specific events (not "allow" per-command checks)
918
+ const skillDecisions = new Set(['init', 'init_skill', 'scanning', 'scan_clean', 'scan_flagged', 'binary_detected', 'files_skipped', 'removed', 'modified', 'deleted']);
919
+ let filtered = localEvents.filter(e => e.guard === 'skill' && skillDecisions.has(e.decision));
920
+ if (blockedOnly) {
921
+ filtered = filtered.filter(e => e.decision === 'scan_flagged' || e.decision === 'binary_detected');
922
+ }
923
+ if (filtered.length === 0) {
924
+ container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No flagged skill events.' : 'No skill activity yet.') + '</div>';
925
+ return;
926
+ }
927
+ container.innerHTML = `<table><thead><tr>
928
+ <th>Time</th>
929
+ <th>Event</th>
930
+ <th>Name</th>
931
+ <th>Type</th>
932
+ <th>Path</th>
933
+ <th>Files</th>
934
+ <th>Detail</th>
935
+ </tr></thead><tbody>` +
936
+ filtered.map((e, i) => {
937
+ const badgeMap = { init: 'badge-gray', init_skill: 'badge-gray', scanning: 'badge-blue', scan_clean: 'badge-green', scan_flagged: 'badge-red', binary_detected: 'badge-red', files_skipped: 'badge-yellow', removed: 'badge-yellow', modified: 'badge-blue', deleted: 'badge-yellow' };
938
+ const labelMap = { init: 'Init', init_skill: 'Discovered', scanning: 'Scanning', scan_clean: 'Clean', scan_flagged: 'Flagged', binary_detected: 'Binary', files_skipped: 'Skipped', removed: 'Removed', modified: 'Modified', deleted: 'Deleted' };
939
+ const badge = badgeMap[e.decision] || 'badge-gray';
940
+ const label = labelMap[e.decision] || e.decision;
941
+ const skillName = e.detail?.skill_name || '-';
942
+ const skillPath = e.detail?.skill_path || '';
943
+ const entryType = e.detail?.type || 'skill';
944
+ const typeBadge = entryType === 'plugin'
945
+ ? '<span class="badge badge-blue">Plugin</span>'
946
+ : '<span class="badge" style="background:rgba(255,255,255,0.06);color:#a1a1aa">Skill</span>';
947
+ const fileCount = e.detail?.file_count ?? e.detail?.file_names?.length ?? '';
948
+ const fileNames = e.detail?.file_names || [];
949
+ const binaryFiles = e.detail?.binary_files || [];
950
+ const uid = 'skill-log-' + i;
951
+ // Build detail string
952
+ let detailParts = [];
953
+ if (e.reason && e.decision !== 'init') detailParts.push(esc(e.reason));
954
+ let detailExtra = '';
955
+ if (fileNames.length > 0 || binaryFiles.length > 0 || e.detail) {
956
+ const detailObj = {};
957
+ if (fileNames.length > 0) detailObj.files = fileNames;
958
+ if (binaryFiles.length > 0) detailObj.binary_files = binaryFiles;
959
+ if (e.detail?.skipped_files?.length > 0) detailObj.skipped_files = e.detail.skipped_files;
960
+ if (e.detail?.total_size_bytes) detailObj.total_size = (e.detail.total_size_bytes / 1024).toFixed(1) + ' KB';
961
+ if (e.detail?.directories) detailObj.directories = e.detail.directories;
962
+ if (e.detail?.cached !== undefined) detailObj.cached = e.detail.cached;
963
+ if (Object.keys(detailObj).length > 0) {
964
+ detailExtra = ` <span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">[detail]</span><div class="payload-content" id="${uid}">${esc(JSON.stringify(detailObj, null, 2))}</div>`;
965
+ }
966
+ }
967
+ return `<tr>
968
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
969
+ <td><span class="badge ${badge}">${label}</span></td>
970
+ <td style="font-family:ui-monospace,monospace;font-size:13px">${esc(skillName)}</td>
971
+ <td>${typeBadge}</td>
972
+ <td style="font-size:12px;color:#a1a1aa;max-width:200px;word-break:break-all">${esc(skillPath)}</td>
973
+ <td style="text-align:center;color:#a1a1aa">${fileCount}</td>
974
+ <td style="font-size:12px;color:#a1a1aa;max-width:350px">${detailParts.join('') + detailExtra}</td>
975
+ </tr>`;
976
+ }).join('') + '</tbody></table>';
977
+ }
978
+ }
979
+
980
+ // =============================================
981
+ // Prompt Injection Page
982
+ // =============================================
983
+ function renderPiPage() {
984
+ const serverOnly = document.getElementById('pi-server-only')?.checked;
985
+ const blockedOnly = document.getElementById('pi-blocked-only')?.checked;
986
+ const container = document.getElementById('pi-table-body');
987
+
988
+ if (serverOnly) {
989
+ const filtered = serverEvents.filter(SERVER_GUARD.pi || (() => false));
990
+ if (filtered.length === 0) {
991
+ container.innerHTML = '<div class="empty">No server send events for prompt injection guard.</div>';
992
+ return;
993
+ }
994
+ container.innerHTML = `<table><thead><tr><th>Time</th><th>Destination</th><th>Event</th><th>Payload</th></tr></thead><tbody>` +
995
+ filtered.map((e, i) => {
996
+ const dest = e.destination === 'posthog' ? '<span class="badge badge-blue">PostHog</span>' : '<span class="badge badge-yellow">Supabase</span>';
997
+ const payloadStr = JSON.stringify(e.properties || e.payload || {}, null, 2);
998
+ const uid = 'pi-sv-' + i;
999
+ return `<tr>
1000
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
1001
+ <td>${dest}</td>
1002
+ <td>${esc(e.event || '-')}</td>
1003
+ <td><span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">View payload</span><div class="payload-content" id="${uid}">${esc(payloadStr)}</div></td>
1004
+ </tr>`;
1005
+ }).join('') + '</tbody></table>';
1006
+ } else {
1007
+ // Show only PI-specific scan events (not per-command allow checks)
1008
+ const piDecisions = new Set(['scanning', 'scan_clean', 'scan_flagged']);
1009
+ let filtered = localEvents.filter(e => e.guard === 'prompt_injection' && piDecisions.has(e.decision));
1010
+ if (blockedOnly) {
1011
+ filtered = filtered.filter(e => e.decision === 'scan_flagged');
1012
+ }
1013
+ if (filtered.length === 0) {
1014
+ container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No flagged prompt injection events.' : 'No prompt injection scans yet. Scans trigger when matching command output is detected.') + '</div>';
1015
+ return;
1016
+ }
1017
+ container.innerHTML = `<table><thead><tr>
1018
+ <th>Time</th>
1019
+ <th>Decision</th>
1020
+ <th>Pattern Matched</th>
1021
+ <th>Command</th>
1022
+ <th>Haiku Decision</th>
1023
+ <th>Haiku Reason</th>
1024
+ <th>I/O</th>
1025
+ </tr></thead><tbody>` +
1026
+ filtered.map((e, i) => {
1027
+ const badgeMap = { scanning: 'badge-blue', scan_clean: 'badge-green', scan_flagged: 'badge-red' };
1028
+ const labelMap = { scanning: 'Scanning', scan_clean: 'Clean', scan_flagged: 'Flagged' };
1029
+ const badge = badgeMap[e.decision] || 'badge-gray';
1030
+ const label = labelMap[e.decision] || e.decision;
1031
+ const pattern = e.detail?.matched_pattern || '-';
1032
+ const modelOutput = e.detail?.model_output;
1033
+ const haikuDecision = modelOutput ? (modelOutput.suspicious ? '<span class="badge badge-red">Suspicious</span>' : '<span class="badge badge-green">Clean</span>') : (e.decision === 'scanning' ? '<span class="badge badge-blue">Pending</span>' : '-');
1034
+ const haikuReason = modelOutput?.reason || '-';
1035
+ // I/O toggle
1036
+ const uid = 'pi-io-' + i;
1037
+ const modelInput = e.detail?.model_input || '';
1038
+ const modelOutputStr = modelOutput ? JSON.stringify(modelOutput, null, 2) : '';
1039
+ const hasIO = modelInput || modelOutputStr;
1040
+ const ioHtml = hasIO
1041
+ ? `<span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">View</span><div class="payload-content" id="${uid}"><b>Input (command output):</b>\n${esc(typeof modelInput === 'string' ? modelInput.slice(0, 3000) : JSON.stringify(modelInput).slice(0, 3000))}\n\n<b>Haiku response:</b>\n${esc(modelOutputStr)}</div>`
1042
+ : '-';
1043
+ return `<tr>
1044
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
1045
+ <td><span class="badge ${badge}">${label}</span></td>
1046
+ <td style="font-family:ui-monospace,monospace;font-size:12px;max-width:200px;word-break:break-all">${esc(pattern)}</td>
1047
+ <td class="cmd" style="max-width:250px" title="${esc(e.command || '')}">${esc(e.command || '-')}</td>
1048
+ <td>${haikuDecision}</td>
1049
+ <td style="font-size:12px;color:#a1a1aa;max-width:300px">${esc(haikuReason)}</td>
1050
+ <td>${ioHtml}</td>
1051
+ </tr>`;
1052
+ }).join('') + '</tbody></table>';
1053
+ }
1054
+ }
1055
+
1056
+ // =============================================
1057
+ // Secrets Guard Page
1058
+ // =============================================
1059
+ function renderSecretsPage() {
1060
+ const serverOnly = document.getElementById('secrets-server-only')?.checked;
1061
+ const blockedOnly = document.getElementById('secrets-blocked-only')?.checked;
1062
+ const container = document.getElementById('secrets-table-body');
1063
+
1064
+ if (serverOnly) {
1065
+ const filtered = serverEvents.filter(SERVER_GUARD.secrets || (() => false));
1066
+ if (filtered.length === 0) {
1067
+ container.innerHTML = '<div class="empty">No server send events for secrets guard.</div>';
1068
+ return;
1069
+ }
1070
+ container.innerHTML = `<table><thead><tr><th>Time</th><th>Destination</th><th>Event</th><th>Payload</th></tr></thead><tbody>` +
1071
+ filtered.map((e, i) => {
1072
+ const dest = e.destination === 'posthog' ? '<span class="badge badge-blue">PostHog</span>' : '<span class="badge badge-yellow">Supabase</span>';
1073
+ const payloadStr = JSON.stringify(e.properties || e.payload || {}, null, 2);
1074
+ const uid = 'sec-sv-' + i;
1075
+ return `<tr>
1076
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
1077
+ <td>${dest}</td>
1078
+ <td>${esc(e.event || '-')}</td>
1079
+ <td><span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">View payload</span><div class="payload-content" id="${uid}">${esc(payloadStr)}</div></td>
1080
+ </tr>`;
1081
+ }).join('') + '</tbody></table>';
1082
+ } else {
1083
+ let filtered = localEvents.filter(e => (e.guard === 'env_var' || e.event === 'output_redacted') && e.decision !== 'allow');
1084
+ if (blockedOnly) {
1085
+ filtered = filtered.filter(e => e.decision === 'block' || e.event === 'output_redacted');
1086
+ }
1087
+ // Hide system commands
1088
+ filtered = filtered.filter(e => !e.command || !/^(networksetup|arp|defaults)\b/.test(e.command.trim()));
1089
+ if (filtered.length === 0) {
1090
+ container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No blocked/redacted events.' : 'No secrets guard events yet.') + '</div>';
1091
+ return;
1092
+ }
1093
+ container.innerHTML = `<table><thead><tr>
1094
+ <th>Time</th>
1095
+ <th>Decision</th>
1096
+ <th>Type</th>
1097
+ <th>Pattern Matched</th>
1098
+ <th>Vars / Secrets</th>
1099
+ <th>Command</th>
1100
+ <th>Reason</th>
1101
+ </tr></thead><tbody>` +
1102
+ filtered.map((e, i) => {
1103
+ const badgeMap = { block: 'badge-red', redact: 'badge-yellow', log: 'badge-yellow', allow: 'badge-green' };
1104
+ const labelMap = { block: 'Blocked', redact: 'Redacted', log: 'Logged', allow: 'Allowed' };
1105
+ const badge = badgeMap[e.decision] || 'badge-gray';
1106
+ const label = labelMap[e.decision] || (e.decision || '-');
1107
+
1108
+ // Type column
1109
+ const type = e.detail?.type || (e.event === 'output_redacted' ? 'output_redacted' : '-');
1110
+ const typeLabel = { env_dump: 'Env Dump', value_exposed: 'Value Exposed', lang_env_access: 'Lang API', env_ref_logged: 'Env Ref', output_redacted: 'Output Redacted' }[type] || type;
1111
+
1112
+ // Pattern matched
1113
+ const pattern = e.detail?.matched_pattern || (e.detail?.matched_patterns ? e.detail.matched_patterns.join(', ') : (e.detail?.pattern_category || '-'));
1114
+
1115
+ // Vars / secrets
1116
+ const vars = e.detail?.vars || e.detail?.secrets || [];
1117
+ const varsStr = Array.isArray(vars) ? vars.join(', ') : String(vars);
1118
+
1119
+ const uid = 'sec-lc-' + i;
1120
+ const detailObj = e.detail ? JSON.stringify(e.detail, null, 2) : '';
1121
+ const detailHtml = detailObj ? ` <span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">[detail]</span><div class="payload-content" id="${uid}">${esc(detailObj)}</div>` : '';
1122
+
1123
+ return `<tr>
1124
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
1125
+ <td><span class="badge ${badge}">${label}</span></td>
1126
+ <td style="font-size:12px"><span class="badge badge-gray">${esc(typeLabel)}</span></td>
1127
+ <td style="font-family:ui-monospace,monospace;font-size:11px;max-width:200px;word-break:break-all">${esc(pattern)}</td>
1128
+ <td style="font-family:ui-monospace,monospace;font-size:12px;color:#facc15;max-width:180px;word-break:break-all">${esc(varsStr)}</td>
1129
+ <td class="cmd" style="max-width:250px" title="${esc(e.command || '')}">${esc(e.command || '-')}</td>
1130
+ <td style="font-size:12px;color:#a1a1aa;max-width:300px">${esc(e.reason || '-')}${detailHtml}</td>
1131
+ </tr>`;
1132
+ }).join('') + '</tbody></table>';
1133
+ }
1134
+ }
1135
+
1136
+ // =============================================
1137
+ // Exfil Allowlist
1138
+ // =============================================
1139
+ let exfilAllowlist = { enabled: false, domains: [] };
1140
+
1141
+ async function loadExfilAllowlist() {
1142
+ try {
1143
+ const res = await fetch('/api/exfil-allowlist');
1144
+ exfilAllowlist = await res.json();
1145
+ } catch { exfilAllowlist = { enabled: false, domains: [] }; }
1146
+ renderExfilAllowlist();
1147
+ }
1148
+
1149
+ function renderExfilAllowlist() {
1150
+ const al = exfilAllowlist || { enabled: false, domains: [] };
1151
+ const statusBadge = document.getElementById('exfil-al-status-badge');
1152
+ const toggleBtn = document.getElementById('exfil-al-toggle-btn');
1153
+ const domainsDiv = document.getElementById('exfil-al-domains');
1154
+ if (!statusBadge) return;
1155
+
1156
+ if (al.enabled) {
1157
+ statusBadge.innerHTML = '<span class="badge badge-green">Enabled</span>';
1158
+ toggleBtn.textContent = 'Disable';
1159
+ toggleBtn.style.color = '#facc15';
1160
+ toggleBtn.style.borderColor = 'rgba(250,204,21,0.3)';
1161
+ } else {
1162
+ statusBadge.innerHTML = '<span class="badge badge-gray">Disabled</span>';
1163
+ toggleBtn.textContent = 'Enable';
1164
+ toggleBtn.style.color = '#4ade80';
1165
+ toggleBtn.style.borderColor = 'rgba(34,197,94,0.3)';
1166
+ }
1167
+
1168
+ if (al.domains.length === 0) {
1169
+ domainsDiv.innerHTML = '<div style="font-size:13px;color:#52525b;padding:8px 0">No domains configured.</div>';
1170
+ } else {
1171
+ domainsDiv.innerHTML = al.domains.map(d =>
1172
+ `<div style="display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:4px 10px;margin:3px 4px 3px 0;font-size:13px;font-family:ui-monospace,monospace">
1173
+ ${esc(d)}
1174
+ <span style="cursor:pointer;color:#a1a1aa;font-size:16px;line-height:1" onclick="removeExfilDomain('${esc(d)}')" title="Remove">&times;</span>
1175
+ </div>`
1176
+ ).join('');
1177
+ }
1178
+ }
1179
+
1180
+ async function addExfilDomain() {
1181
+ const input = document.getElementById('exfil-al-input');
1182
+ const domain = (input.value || '').trim();
1183
+ if (!domain) return;
1184
+ try {
1185
+ const res = await fetch('/api/exfil-allowlist', {
1186
+ method: 'POST',
1187
+ headers: { 'Content-Type': 'application/json' },
1188
+ body: JSON.stringify({ action: 'add', domain }),
1189
+ });
1190
+ const data = await res.json();
1191
+ if (data.allowlist) exfilAllowlist = data.allowlist;
1192
+ input.value = '';
1193
+ renderExfilAllowlist();
1194
+ } catch (e) { alert('Failed: ' + e.message); }
1195
+ }
1196
+
1197
+ async function removeExfilDomain(domain) {
1198
+ try {
1199
+ const res = await fetch('/api/exfil-allowlist', {
1200
+ method: 'POST',
1201
+ headers: { 'Content-Type': 'application/json' },
1202
+ body: JSON.stringify({ action: 'remove', domain }),
1203
+ });
1204
+ const data = await res.json();
1205
+ if (data.allowlist) exfilAllowlist = data.allowlist;
1206
+ renderExfilAllowlist();
1207
+ } catch (e) { alert('Failed: ' + e.message); }
1208
+ }
1209
+
1210
+ async function toggleExfilAllowlist() {
1211
+ const action = exfilAllowlist.enabled ? 'disable' : 'enable';
1212
+ try {
1213
+ const res = await fetch('/api/exfil-allowlist', {
1214
+ method: 'POST',
1215
+ headers: { 'Content-Type': 'application/json' },
1216
+ body: JSON.stringify({ action }),
1217
+ });
1218
+ const data = await res.json();
1219
+ if (data.allowlist) exfilAllowlist = data.allowlist;
1220
+ renderExfilAllowlist();
1221
+ } catch (e) { alert('Failed: ' + e.message); }
1222
+ }
1223
+
1224
+ // =============================================
1225
+ // Plugin Sandbox Page
1226
+ // =============================================
1227
+ function renderSandboxPage() {
1228
+ const filter = document.getElementById('sandbox-filter')?.value || 'all';
1229
+ const container = document.getElementById('sandbox-table-body');
1230
+
1231
+ const sandboxEvents = localEvents.filter(e => e.guard === 'sandbox');
1232
+ const scrubs = sandboxEvents.filter(e => e.decision === 'env_scrubbed');
1233
+ const fsBlocks = sandboxEvents.filter(e => e.decision === 'fs_blocked');
1234
+ const netLogs = sandboxEvents.filter(e => e.decision === 'network_logged');
1235
+
1236
+ // Update stat cards
1237
+ const el = (id) => document.getElementById(id);
1238
+ if (el('sandbox-scrubs-count')) el('sandbox-scrubs-count').textContent = scrubs.length;
1239
+ if (el('sandbox-fs-count')) el('sandbox-fs-count').textContent = fsBlocks.length;
1240
+ if (el('sandbox-net-count')) el('sandbox-net-count').textContent = netLogs.length;
1241
+
1242
+ let filtered = sandboxEvents;
1243
+ if (filter === 'env_scrubbed') filtered = scrubs;
1244
+ else if (filter === 'fs_blocked') filtered = fsBlocks;
1245
+ else if (filter === 'network_logged') filtered = netLogs;
1246
+
1247
+ if (filtered.length === 0) {
1248
+ container.innerHTML = '<div class="empty">No sandbox events yet. Events appear when plugin MCP servers are spawned.</div>';
1249
+ return;
1250
+ }
1251
+
1252
+ container.innerHTML = `<table><thead><tr>
1253
+ <th>Time</th>
1254
+ <th>Type</th>
1255
+ <th>Detail</th>
1256
+ <th>Extra</th>
1257
+ </tr></thead><tbody>` +
1258
+ filtered.map((e, i) => {
1259
+ const badgeMap = { env_scrubbed: 'badge-blue', fs_blocked: 'badge-red', network_logged: 'badge-yellow' };
1260
+ const labelMap = { env_scrubbed: 'Env Scrub', fs_blocked: 'FS Block', network_logged: 'Network' };
1261
+ const badge = badgeMap[e.decision] || 'badge-gray';
1262
+ const label = labelMap[e.decision] || e.decision;
1263
+
1264
+ let detail = esc(e.reason || '-');
1265
+ const uid = 'sandbox-lc-' + i;
1266
+ const detailObj = e.detail ? JSON.stringify(e.detail, null, 2) : '';
1267
+ const detailHtml = detailObj ? `<span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">[detail]</span><div class="payload-content" id="${uid}">${esc(detailObj)}</div>` : '';
1268
+
1269
+ return `<tr>
1270
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
1271
+ <td><span class="badge ${badge}">${label}</span></td>
1272
+ <td style="font-size:12px;color:#a1a1aa;max-width:400px">${detail}</td>
1273
+ <td style="font-size:12px;color:#a1a1aa;max-width:300px">${detailHtml}</td>
1274
+ </tr>`;
1275
+ }).join('') + '</tbody></table>';
1276
+ }
1277
+
1278
+ const SYSTEM_COMMANDS = /^(networksetup|arp|defaults)\b/;
1279
+
433
1280
  function renderGuardPage(guard) {
434
1281
  const serverOnly = document.getElementById(guard + '-server-only')?.checked;
435
1282
  const blockedOnly = document.getElementById(guard + '-blocked-only')?.checked;
1283
+ const showPassed = document.getElementById(guard + '-show-passed')?.checked;
1284
+ const showSystem = document.getElementById(guard + '-show-system')?.checked;
436
1285
  const container = document.getElementById(guard + '-table-body');
437
1286
 
438
1287
  if (serverOnly) {
439
- // Show server send events filtered to this guard
440
1288
  const filtered = serverEvents.filter(SERVER_GUARD[guard] || (() => false));
441
1289
  if (filtered.length === 0) {
442
1290
  container.innerHTML = '<div class="empty">No server send events for this guard.</div>';
@@ -455,31 +1303,38 @@ function renderGuardPage(guard) {
455
1303
  </tr>`;
456
1304
  }).join('') + '</tbody></table>';
457
1305
  } else {
458
- // Show all command events; optionally filter to only blocked by this guard
459
- const guardBlockerFn = GUARD_EVENTS[guard] || (() => false);
1306
+ const guardAllFn = GUARD_EVENTS[guard] || (() => false);
1307
+ const guardBlockedFn = GUARD_BLOCKED[guard] || (() => false);
460
1308
  let filtered;
461
1309
  if (blockedOnly) {
462
- filtered = localEvents.filter(e => guardBlockerFn(e));
1310
+ filtered = localEvents.filter(e => guardBlockedFn(e));
463
1311
  } else {
464
- // Show all command_check + command_blocked + output_redacted events
465
- filtered = localEvents.filter(e => e.event === 'command_check' || e.event === 'command_blocked' || e.event === 'output_redacted');
1312
+ filtered = localEvents.filter(e => guardAllFn(e));
1313
+ }
1314
+ // Hide system commands (networksetup, arp, defaults) unless checkbox is checked
1315
+ if (!showSystem) {
1316
+ filtered = filtered.filter(e => !e.command || !SYSTEM_COMMANDS.test(e.command.trim()));
466
1317
  }
467
1318
  if (filtered.length === 0) {
468
1319
  container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No blocked events for this guard.' : 'No events yet. Events appear when openclaw-secure is active.') + '</div>';
469
1320
  return;
470
1321
  }
471
- container.innerHTML = `<table><thead><tr><th>Time</th><th>Decision</th><th>Command</th><th>Blocker</th><th>Detail</th></tr></thead><tbody>` +
472
- filtered.map(e => {
473
- const badge = e.decision === 'block' ? 'badge-red' : e.decision === 'redact' ? 'badge-yellow' : 'badge-green';
474
- const label = e.decision === 'block' ? 'Blocked' : e.decision === 'redact' ? 'Redacted' : 'Allowed';
475
- const blocker = e.blocker ? esc(e.blocker) : '-';
476
- const detail = e.reason ? esc(e.reason) : (e.secrets_count ? `${e.secrets_count} secret(s) redacted` : '');
1322
+ const cmdHeader = guard === 'skill' ? 'Skill File' : 'Command';
1323
+ container.innerHTML = `<table><thead><tr><th>Time</th><th>Decision</th><th>${cmdHeader}</th><th>Detail</th></tr></thead><tbody>` +
1324
+ filtered.map((e, i) => {
1325
+ const badgeMap = { block: 'badge-red', scan_flagged: 'badge-red', binary_detected: 'badge-red', redact: 'badge-yellow', log: 'badge-yellow', scanning: 'badge-blue', scan_clean: 'badge-green', allow: 'badge-green', init: 'badge-gray' };
1326
+ const labelMap = { block: 'Blocked', scan_flagged: 'Flagged', binary_detected: 'Binary', redact: 'Redacted', log: 'Logged', scanning: 'Scanning', scan_clean: 'Clean', allow: 'Allowed', init: 'Init' };
1327
+ const badge = badgeMap[e.decision] || 'badge-gray';
1328
+ const label = labelMap[e.decision] || (e.decision || '-');
1329
+ const reason = e.reason ? esc(e.reason) : (e.secrets_count ? `${e.secrets_count} secret(s) redacted` : '');
1330
+ const detailObj = e.detail ? JSON.stringify(e.detail, null, 2) : '';
1331
+ const uid = guard + '-lc-' + i;
1332
+ const detailHtml = reason + (detailObj ? ` <span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">[detail]</span><div class="payload-content" id="${uid}">${esc(detailObj)}</div>` : '');
477
1333
  return `<tr>
478
1334
  <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
479
1335
  <td><span class="badge ${badge}">${label}</span></td>
480
1336
  <td class="cmd" title="${esc(e.command || '')}">${esc(e.command || '-')}</td>
481
- <td style="font-size:12px">${blocker}</td>
482
- <td style="font-size:12px;color:#a1a1aa;max-width:300px;overflow:hidden;text-overflow:ellipsis">${detail}</td>
1337
+ <td style="font-size:12px;color:#a1a1aa;max-width:400px">${detailHtml}</td>
483
1338
  </tr>`;
484
1339
  }).join('') + '</tbody></table>';
485
1340
  }
@@ -508,8 +1363,236 @@ function esc(s) {
508
1363
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
509
1364
  }
510
1365
 
1366
+ // =============================================
1367
+ // Secret Scanner
1368
+ // =============================================
1369
+ let scanData = null; // last scan results from API
1370
+
1371
+ async function loadScanStatus() {
1372
+ try {
1373
+ const res = await fetch('/api/scan');
1374
+ const data = await res.json();
1375
+ renderScanStatus(data);
1376
+ } catch (e) {
1377
+ console.error('Failed to load scan status:', e);
1378
+ }
1379
+ }
1380
+
1381
+ function renderScanStatus(data) {
1382
+ const el = (id) => document.getElementById(id);
1383
+ // TruffleHog status
1384
+ if (el('scan-thog-status')) {
1385
+ el('scan-thog-status').innerHTML = data.installed ? 'Installed' : 'Not installed<div style="font-size:11px;color:#a1a1aa;margin-top:4px">Run: <code style="background:#27272a;padding:2px 6px;border-radius:4px">brew install trufflehog</code></div>';
1386
+ el('scan-thog-status').style.color = data.installed ? '#4ade80' : '#f87171';
1387
+ }
1388
+ // Home page summary + findings from fresh scan data
1389
+ if (data.fresh) {
1390
+ const f = data.fresh;
1391
+ const verified = (f.findings || []).filter(x => x.verified).length;
1392
+ const total = (f.findings || []).length;
1393
+ const ago = f.summary && f.summary.scannedAt ? timeAgo(f.summary.scannedAt) : 'Just now';
1394
+ if (el('scan-last-time')) el('scan-last-time').textContent = ago;
1395
+ if (el('scan-verified')) el('scan-verified').textContent = verified;
1396
+ if (el('scan-total')) el('scan-total').textContent = total;
1397
+ if (el('home-scan-status')) {
1398
+ if (verified > 0) {
1399
+ el('home-scan-status').innerHTML = `<span style="color:#f87171">${verified} live secret${verified > 1 ? 's' : ''} found</span>`;
1400
+ } else if (total > 0) {
1401
+ el('home-scan-status').textContent = `${total} potential finding${total > 1 ? 's' : ''} (none verified)`;
1402
+ } else {
1403
+ el('home-scan-status').innerHTML = '<span style="color:#4ade80">No hardcoded secrets found</span>';
1404
+ }
1405
+ }
1406
+ if (el('home-scan-detail')) {
1407
+ el('home-scan-detail').textContent = `Last scan: ${ago}` + (f.summary ? ` · ${f.summary.targetsScanned || 0} directories scanned` : '');
1408
+ }
1409
+ // Reset Run Scan button
1410
+ if (el('scan-run-btn')) { el('scan-run-btn').disabled = false; el('scan-run-btn').innerHTML = '<svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan'; }
1411
+ // Populate findings table if not already set by a manual scan
1412
+ if (!scanData) {
1413
+ scanData = f;
1414
+ renderScanFindings();
1415
+ }
1416
+ } else if (data.scanning) {
1417
+ if (el('scan-last-time')) el('scan-last-time').textContent = 'Scanning...';
1418
+ if (el('scan-verified')) el('scan-verified').textContent = '-';
1419
+ if (el('scan-total')) el('scan-total').textContent = '-';
1420
+ if (el('home-scan-status')) el('home-scan-status').textContent = 'Scanning directories for secrets...';
1421
+ if (el('home-scan-detail')) el('home-scan-detail').textContent = '';
1422
+ // Update scan page too
1423
+ if (el('scan-findings-body')) el('scan-findings-body').innerHTML = '<div class="empty">Scanning... this may take a minute.</div>';
1424
+ if (el('scan-run-btn')) { el('scan-run-btn').disabled = true; el('scan-run-btn').innerHTML = '<svg style="width:14px;height:14px;animation:spin 1s linear infinite" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg> Scanning...'; }
1425
+ if (!document.getElementById('spin-style')) { const style = document.createElement('style'); style.id = 'spin-style'; style.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'; document.head.appendChild(style); }
1426
+ if (el('scan-solve-btn')) el('scan-solve-btn').style.display = 'none';
1427
+ if (el('scan-select-all-wrap')) el('scan-select-all-wrap').style.display = 'none';
1428
+ } else {
1429
+ if (el('scan-last-time')) el('scan-last-time').textContent = 'Never';
1430
+ if (el('scan-verified')) el('scan-verified').textContent = '-';
1431
+ if (el('scan-total')) el('scan-total').textContent = '-';
1432
+ if (el('home-scan-status')) el('home-scan-status').textContent = data.installed ? 'No scan data yet. Scan starting...' : 'TruffleHog not installed. Install with: brew install trufflehog';
1433
+ if (el('home-scan-detail')) el('home-scan-detail').textContent = '';
1434
+ }
1435
+ }
1436
+
1437
+ async function runScan() {
1438
+ const btn = document.getElementById('scan-run-btn');
1439
+ btn.disabled = true;
1440
+ btn.innerHTML = '<svg style="width:14px;height:14px;animation:spin 1s linear infinite" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg> Scanning...';
1441
+ if (!document.getElementById('spin-style')) {
1442
+ const style = document.createElement('style');
1443
+ style.id = 'spin-style';
1444
+ style.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
1445
+ document.head.appendChild(style);
1446
+ }
1447
+ document.getElementById('scan-findings-body').innerHTML = '<div class="empty">Scanning... this may take a minute.</div>';
1448
+ try {
1449
+ await fetch('/api/scan', { method: 'POST' });
1450
+ // Scan runs in background on server — poll until done
1451
+ await pollScanResults();
1452
+ } catch (e) {
1453
+ document.getElementById('scan-findings-body').innerHTML = '<div class="empty">Scan request sent. Polling for results...</div>';
1454
+ await pollScanResults();
1455
+ }
1456
+ }
1457
+
1458
+ async function pollScanResults() {
1459
+ const btn = document.getElementById('scan-run-btn');
1460
+ const maxPolls = 120; // 4 minutes max (2s intervals)
1461
+ for (let i = 0; i < maxPolls; i++) {
1462
+ await new Promise(r => setTimeout(r, 2000));
1463
+ try {
1464
+ const res = await fetch('/api/scan');
1465
+ const data = await res.json();
1466
+ if (data.scanning) continue; // still in progress
1467
+ // Scan complete — use fresh results if available
1468
+ if (data.fresh) {
1469
+ scanData = data.fresh;
1470
+ }
1471
+ // Update status cards
1472
+ renderScanStatus(data);
1473
+ if (scanData && scanData.targets) {
1474
+ document.getElementById('scan-targets-section').style.display = '';
1475
+ document.getElementById('scan-targets-list').innerHTML = scanData.targets.map(t =>
1476
+ `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;font-size:13px;color:#a1a1aa"><span style="color:#4ade80">&#10003;</span> ${esc(t.label)}</div>`
1477
+ ).join('');
1478
+ }
1479
+ renderScanFindings();
1480
+ btn.disabled = false;
1481
+ btn.innerHTML = '<svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan';
1482
+ return;
1483
+ } catch {}
1484
+ }
1485
+ btn.disabled = false;
1486
+ btn.innerHTML = '<svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan';
1487
+ document.getElementById('scan-findings-body').innerHTML = '<div class="empty">Scan timed out. Check server logs.</div>';
1488
+ }
1489
+
1490
+ function renderScanFindings() {
1491
+ const container = document.getElementById('scan-findings-body');
1492
+ const solveBtn = document.getElementById('scan-solve-btn');
1493
+ const selectAllWrap = document.getElementById('scan-select-all-wrap');
1494
+ if (!scanData || !scanData.findings || scanData.findings.length === 0) {
1495
+ container.innerHTML = '<div class="empty">No findings. Your codebase is clean!</div>';
1496
+ solveBtn.style.display = 'none';
1497
+ selectAllWrap.style.display = 'none';
1498
+ return;
1499
+ }
1500
+ const findings = scanData.findings;
1501
+ const hasVerified = findings.some(f => f.verified);
1502
+ solveBtn.style.display = hasVerified ? '' : 'none';
1503
+ selectAllWrap.style.display = hasVerified ? '' : 'none';
1504
+ container.innerHTML = `<table><thead><tr>
1505
+ <th style="width:32px"></th>
1506
+ <th>Detector</th>
1507
+ <th>File</th>
1508
+ <th>Line</th>
1509
+ <th>Status</th>
1510
+ <th>Secret (redacted)</th>
1511
+ <th>Scan Target</th>
1512
+ </tr></thead><tbody>` +
1513
+ findings.map((f, i) => {
1514
+ const verified = f.verified ? '<span class="badge badge-red">LIVE</span>' : '<span class="badge badge-gray">Inactive</span>';
1515
+ const checkDisabled = f.verified ? '' : 'disabled';
1516
+ return `<tr>
1517
+ <td><input type="checkbox" class="scan-check" data-index="${f.index}" ${checkDisabled}></td>
1518
+ <td style="font-weight:500">${esc(f.detectorName)}</td>
1519
+ <td class="cmd" title="${esc(f.file || '')}">${esc(f.file ? f.file.replace(/^\/Users\/[^/]+/, '~') : '-')}</td>
1520
+ <td style="color:#a1a1aa">${f.line || '-'}</td>
1521
+ <td>${verified}</td>
1522
+ <td style="font-family:ui-monospace,monospace;font-size:12px;color:#a1a1aa">${esc(f.raw)}</td>
1523
+ <td style="font-size:12px;color:#a1a1aa">${esc(f.scanTarget || '')}</td>
1524
+ </tr>`;
1525
+ }).join('') + '</tbody></table>';
1526
+ }
1527
+
1528
+ function toggleScanSelectAll() {
1529
+ const checked = document.getElementById('scan-select-all')?.checked;
1530
+ document.querySelectorAll('.scan-check:not(:disabled)').forEach(cb => { cb.checked = checked; });
1531
+ }
1532
+
1533
+ async function runSolve() {
1534
+ const checks = document.querySelectorAll('.scan-check:checked');
1535
+ const indices = [...checks].map(cb => parseInt(cb.dataset.index));
1536
+ if (indices.length === 0) {
1537
+ alert('No findings selected. Check the boxes next to verified secrets to scrub.');
1538
+ return;
1539
+ }
1540
+ if (!confirm(`Replace ${indices.length} secret(s) in files with dummy values? This cannot be undone.`)) return;
1541
+ const btn = document.getElementById('scan-solve-btn');
1542
+ btn.disabled = true;
1543
+ btn.textContent = 'Scrubbing...';
1544
+ try {
1545
+ const res = await fetch('/api/solve', {
1546
+ method: 'POST',
1547
+ headers: { 'Content-Type': 'application/json' },
1548
+ body: JSON.stringify({ indices }),
1549
+ });
1550
+ const data = await res.json();
1551
+ if (data.error) {
1552
+ alert('Solve error: ' + data.error);
1553
+ return;
1554
+ }
1555
+ // Show results
1556
+ const resultsDiv = document.getElementById('scan-solve-results');
1557
+ const resultsBody = document.getElementById('scan-solve-body');
1558
+ resultsDiv.style.display = '';
1559
+ const succeeded = (data.results || []).filter(r => r.success);
1560
+ const failed = (data.results || []).filter(r => !r.success);
1561
+ resultsBody.innerHTML = `<table><thead><tr><th>Status</th><th>File</th><th>Detector</th><th>Original</th><th>Replaced With</th></tr></thead><tbody>` +
1562
+ (data.results || []).map(r => {
1563
+ const status = r.success ? '<span class="badge badge-green">Replaced</span>' : '<span class="badge badge-red">Failed</span>';
1564
+ return `<tr>
1565
+ <td>${status}</td>
1566
+ <td class="cmd">${esc((r.file || '').replace(/^\/Users\/[^/]+/, '~'))}</td>
1567
+ <td>${esc(r.detectorName || '')}</td>
1568
+ <td style="font-family:ui-monospace,monospace;font-size:12px;color:#a1a1aa">${esc(r.original || r.error || '')}</td>
1569
+ <td style="font-family:ui-monospace,monospace;font-size:12px;color:#4ade80">${esc(r.dummy || '')}</td>
1570
+ </tr>`;
1571
+ }).join('') + '</tbody></table>';
1572
+ if (succeeded.length > 0) {
1573
+ resultsBody.innerHTML += `<div style="padding:16px;font-size:13px;color:#a1a1aa">
1574
+ ${succeeded.length} secret(s) replaced with dummy values. Remember to rotate the REAL secrets in their original services.
1575
+ </div>`;
1576
+ }
1577
+ // Clear scan data since files changed
1578
+ scanData = null;
1579
+ document.getElementById('scan-findings-body').innerHTML = '<div class="empty">Scan data cleared after scrub. Run a new scan to verify.</div>';
1580
+ document.getElementById('scan-solve-btn').style.display = 'none';
1581
+ document.getElementById('scan-select-all-wrap').style.display = 'none';
1582
+ // Refresh home summary
1583
+ loadScanStatus();
1584
+ } catch (e) {
1585
+ alert('Solve failed: ' + e.message);
1586
+ } finally {
1587
+ btn.disabled = false;
1588
+ btn.innerHTML = '<svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Scrub Selected';
1589
+ }
1590
+ }
1591
+
511
1592
  refresh();
512
1593
  setInterval(refresh, 10000);
513
1594
  </script>
514
1595
  </body>
515
1596
  </html>
1597
+
1598
+