@panguard-ai/panguard-guard 1.6.0 → 1.7.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli/index.d.ts +9 -0
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +128 -29
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +46 -14
  8. package/dist/config.js.map +1 -1
  9. package/dist/daemon/index.js +20 -18
  10. package/dist/daemon/index.js.map +1 -1
  11. package/dist/dashboard/dashboard.html +2428 -205
  12. package/dist/dashboard/index.d.ts +104 -0
  13. package/dist/dashboard/index.d.ts.map +1 -1
  14. package/dist/dashboard/index.js +710 -58
  15. package/dist/dashboard/index.js.map +1 -1
  16. package/dist/engines/atr-engine.d.ts +24 -2
  17. package/dist/engines/atr-engine.d.ts.map +1 -1
  18. package/dist/engines/atr-engine.js +80 -20
  19. package/dist/engines/atr-engine.js.map +1 -1
  20. package/dist/event-processor.js +6 -4
  21. package/dist/event-processor.js.map +1 -1
  22. package/dist/guard-engine.d.ts +41 -1
  23. package/dist/guard-engine.d.ts.map +1 -1
  24. package/dist/guard-engine.js +124 -19
  25. package/dist/guard-engine.js.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +5 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/investigation/index.js +3 -3
  31. package/dist/investigation/index.js.map +1 -1
  32. package/dist/llm-detect.d.ts +19 -0
  33. package/dist/llm-detect.d.ts.map +1 -1
  34. package/dist/llm-detect.js +72 -5
  35. package/dist/llm-detect.js.map +1 -1
  36. package/dist/memory/baseline.d.ts.map +1 -1
  37. package/dist/memory/baseline.js +4 -7
  38. package/dist/memory/baseline.js.map +1 -1
  39. package/dist/net/validate-outbound-url.d.ts +33 -0
  40. package/dist/net/validate-outbound-url.d.ts.map +1 -0
  41. package/dist/net/validate-outbound-url.js +95 -0
  42. package/dist/net/validate-outbound-url.js.map +1 -0
  43. package/dist/notify/webhook.d.ts.map +1 -1
  44. package/dist/notify/webhook.js +10 -0
  45. package/dist/notify/webhook.js.map +1 -1
  46. package/dist/redact.d.ts +35 -0
  47. package/dist/redact.d.ts.map +1 -0
  48. package/dist/redact.js +39 -0
  49. package/dist/redact.js.map +1 -0
  50. package/dist/response/file-quarantine.d.ts +2 -0
  51. package/dist/response/file-quarantine.d.ts.map +1 -1
  52. package/dist/response/file-quarantine.js +35 -9
  53. package/dist/response/file-quarantine.js.map +1 -1
  54. package/dist/rule-loader.d.ts +14 -1
  55. package/dist/rule-loader.d.ts.map +1 -1
  56. package/dist/rule-loader.js +62 -4
  57. package/dist/rule-loader.js.map +1 -1
  58. package/dist/rule-sync.d.ts +22 -0
  59. package/dist/rule-sync.d.ts.map +1 -1
  60. package/dist/rule-sync.js +152 -66
  61. package/dist/rule-sync.js.map +1 -1
  62. package/dist/threat-cloud/index.d.ts +11 -14
  63. package/dist/threat-cloud/index.d.ts.map +1 -1
  64. package/dist/threat-cloud/index.js +24 -24
  65. package/dist/threat-cloud/index.js.map +1 -1
  66. package/dist/threat-cloud/tc-key-provisioner.d.ts.map +1 -1
  67. package/dist/threat-cloud/tc-key-provisioner.js +19 -1
  68. package/dist/threat-cloud/tc-key-provisioner.js.map +1 -1
  69. package/dist/types.d.ts +9 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js.map +1 -1
  72. package/package.json +12 -12
@@ -12,14 +12,15 @@
12
12
  />
13
13
  <style>
14
14
  :root {
15
- --sage: #8b9a8e;
15
+ --sage: #8b9a8e; /* brand identity / presence */
16
+ --emerald: #34d399; /* action layer: CTAs, positive/active, updates */
16
17
  --bg-base: #1a1614;
17
18
  --bg-card: #242220;
18
19
  --bg-raised: #2e2c2a;
19
20
  --border: #3a3836;
20
21
  --text-primary: #f5f1e8;
21
22
  --text-muted: #a09a94;
22
- --ok: #2ed573;
23
+ --ok: #34d399;
23
24
  --warn: #fbbf24;
24
25
  --danger: #ef4444;
25
26
  --orange: #f97316;
@@ -63,8 +64,8 @@
63
64
  gap: 10px;
64
65
  }
65
66
  .sb-brand svg {
66
- width: 28px;
67
- height: 28px;
67
+ width: 32px;
68
+ height: 32px;
68
69
  flex-shrink: 0;
69
70
  }
70
71
  .sb-brand span {
@@ -74,6 +75,25 @@
74
75
  color: var(--text-primary);
75
76
  letter-spacing: 0.5px;
76
77
  }
78
+ .sb-brand-text {
79
+ display: flex;
80
+ flex-direction: column;
81
+ gap: 4px;
82
+ min-width: 0;
83
+ }
84
+ .sb-brand .brand-tier {
85
+ font-size: 9px;
86
+ font-weight: 700;
87
+ letter-spacing: 0.8px;
88
+ text-transform: uppercase;
89
+ color: var(--sage);
90
+ background: rgba(139, 154, 142, 0.14);
91
+ border: 1px solid rgba(139, 154, 142, 0.3);
92
+ border-radius: 4px;
93
+ padding: 1px 6px;
94
+ align-self: flex-start;
95
+ line-height: 1.6;
96
+ }
77
97
  .sb-nav {
78
98
  flex: 1;
79
99
  padding: 12px 0;
@@ -282,6 +302,224 @@
282
302
  border-left-color: var(--ok);
283
303
  }
284
304
 
305
+ /* Readable event cards (replaces the raw JSON dump rows). A solo dev
306
+ scans these, not parses them: severity, plain summary, time. */
307
+ .ev-card {
308
+ display: flex;
309
+ align-items: center;
310
+ gap: 12px;
311
+ padding: 11px 16px;
312
+ border-bottom: 1px solid var(--border);
313
+ border-left: 3px solid var(--text-muted);
314
+ }
315
+ .ev-card:last-child {
316
+ border-bottom: none;
317
+ }
318
+ .ev-card .ev-sev {
319
+ width: 8px;
320
+ height: 8px;
321
+ border-radius: 50%;
322
+ background: var(--text-muted);
323
+ flex-shrink: 0;
324
+ }
325
+ .ev-card .ev-body {
326
+ flex: 1;
327
+ min-width: 0;
328
+ }
329
+ .ev-card .ev-title {
330
+ font-size: 13px;
331
+ color: var(--text-primary);
332
+ font-weight: 500;
333
+ white-space: nowrap;
334
+ overflow: hidden;
335
+ text-overflow: ellipsis;
336
+ }
337
+ .ev-card .ev-meta {
338
+ font-size: 11px;
339
+ color: var(--text-muted);
340
+ margin-top: 2px;
341
+ font-family: 'JetBrains Mono', 'SF Mono', monospace;
342
+ white-space: nowrap;
343
+ overflow: hidden;
344
+ text-overflow: ellipsis;
345
+ }
346
+ .ev-card .ev-sevtag {
347
+ font-size: 10px;
348
+ font-weight: 700;
349
+ text-transform: uppercase;
350
+ letter-spacing: 0.5px;
351
+ color: var(--text-muted);
352
+ flex-shrink: 0;
353
+ }
354
+ .ev-card .ev-time {
355
+ font-size: 11px;
356
+ color: var(--text-muted);
357
+ font-family: 'JetBrains Mono', 'SF Mono', monospace;
358
+ flex-shrink: 0;
359
+ }
360
+ .ev-card.critical,
361
+ .ev-card.malicious {
362
+ border-left-color: var(--danger);
363
+ }
364
+ .ev-card.critical .ev-sev,
365
+ .ev-card.malicious .ev-sev {
366
+ background: var(--danger);
367
+ }
368
+ .ev-card.critical .ev-sevtag,
369
+ .ev-card.malicious .ev-sevtag {
370
+ color: var(--danger);
371
+ }
372
+ .ev-card.high {
373
+ border-left-color: var(--orange);
374
+ }
375
+ .ev-card.high .ev-sev {
376
+ background: var(--orange);
377
+ }
378
+ .ev-card.high .ev-sevtag {
379
+ color: var(--orange);
380
+ }
381
+ .ev-card.medium,
382
+ .ev-card.suspicious {
383
+ border-left-color: var(--warn);
384
+ }
385
+ .ev-card.medium .ev-sev,
386
+ .ev-card.suspicious .ev-sev {
387
+ background: var(--warn);
388
+ }
389
+ .ev-card.medium .ev-sevtag,
390
+ .ev-card.suspicious .ev-sevtag {
391
+ color: var(--warn);
392
+ }
393
+
394
+ /* First-run welcome guide */
395
+ .welcome {
396
+ position: relative;
397
+ background: linear-gradient(135deg, rgba(52, 211, 153, 0.1), rgba(139, 154, 142, 0.04));
398
+ border: 1px solid var(--border);
399
+ border-radius: var(--radius);
400
+ padding: 22px 24px;
401
+ margin-bottom: 20px;
402
+ box-shadow: var(--glow);
403
+ }
404
+ .welcome-x {
405
+ position: absolute;
406
+ top: 12px;
407
+ right: 14px;
408
+ background: transparent;
409
+ border: 0;
410
+ color: var(--text-muted);
411
+ font-size: 22px;
412
+ line-height: 1;
413
+ cursor: pointer;
414
+ padding: 2px 6px;
415
+ }
416
+ .welcome-x:hover {
417
+ color: var(--text-primary);
418
+ }
419
+ .welcome-head {
420
+ display: flex;
421
+ align-items: center;
422
+ gap: 10px;
423
+ margin-bottom: 8px;
424
+ }
425
+ .welcome-title {
426
+ font-family: 'Space Grotesk', 'Noto Sans TC', sans-serif;
427
+ font-size: 18px;
428
+ font-weight: 700;
429
+ color: var(--text-primary);
430
+ }
431
+ .welcome-lead {
432
+ font-size: 13px;
433
+ color: var(--text-primary);
434
+ line-height: 1.65;
435
+ max-width: 72ch;
436
+ margin-bottom: 18px;
437
+ }
438
+ .welcome-grid {
439
+ display: grid;
440
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
441
+ gap: 24px;
442
+ }
443
+ @media (max-width: 720px) {
444
+ .welcome-grid {
445
+ grid-template-columns: 1fr;
446
+ }
447
+ }
448
+ .welcome-col-h {
449
+ font-size: 11px;
450
+ font-weight: 700;
451
+ text-transform: uppercase;
452
+ letter-spacing: 0.5px;
453
+ color: var(--text-muted);
454
+ margin-bottom: 10px;
455
+ }
456
+ .welcome-opt {
457
+ font-weight: 500;
458
+ text-transform: none;
459
+ letter-spacing: 0;
460
+ color: var(--sage);
461
+ }
462
+ .welcome-list {
463
+ list-style: none;
464
+ padding: 0;
465
+ margin: 0;
466
+ font-size: 13px;
467
+ line-height: 1.9;
468
+ color: var(--text-muted);
469
+ }
470
+ .welcome-list strong {
471
+ color: var(--text-primary);
472
+ font-weight: 600;
473
+ }
474
+ .welcome-step {
475
+ background: var(--bg-raised);
476
+ border: 1px solid var(--border);
477
+ border-radius: var(--radius);
478
+ padding: 12px 14px;
479
+ margin-bottom: 10px;
480
+ }
481
+ .welcome-step:last-child {
482
+ margin-bottom: 0;
483
+ }
484
+ .welcome-step-h {
485
+ font-size: 13px;
486
+ font-weight: 700;
487
+ color: var(--text-primary);
488
+ margin-bottom: 4px;
489
+ }
490
+ .welcome-step-b {
491
+ font-size: 12px;
492
+ color: var(--text-muted);
493
+ line-height: 1.6;
494
+ margin-bottom: 8px;
495
+ }
496
+ .welcome-step-b strong {
497
+ color: var(--text-primary);
498
+ font-weight: 600;
499
+ }
500
+ .welcome-foot {
501
+ display: flex;
502
+ align-items: center;
503
+ gap: 14px;
504
+ margin-top: 18px;
505
+ flex-wrap: wrap;
506
+ }
507
+ .welcome-later {
508
+ background: transparent;
509
+ border: 0;
510
+ color: var(--text-muted);
511
+ font-size: 12px;
512
+ cursor: pointer;
513
+ text-decoration: underline;
514
+ }
515
+ .welcome-later:hover {
516
+ color: var(--text-primary);
517
+ }
518
+ .welcome-foot-note {
519
+ font-size: 11px;
520
+ color: var(--text-muted);
521
+ }
522
+
285
523
  /* Tables */
286
524
  .tb {
287
525
  width: 100%;
@@ -389,7 +627,7 @@
389
627
  font-family: inherit;
390
628
  }
391
629
  .btn-primary {
392
- background: var(--sage);
630
+ background: var(--emerald);
393
631
  color: var(--bg-base);
394
632
  }
395
633
  .btn-primary:hover {
@@ -573,7 +811,7 @@
573
811
  flex-shrink: 0;
574
812
  }
575
813
  .toggle.on {
576
- background: var(--sage);
814
+ background: var(--emerald);
577
815
  }
578
816
  .toggle::after {
579
817
  content: '';
@@ -606,6 +844,145 @@
606
844
  font-weight: 600;
607
845
  }
608
846
 
847
+ /* Collective-defense value/privacy explainer */
848
+ .cd-grid {
849
+ display: grid;
850
+ grid-template-columns: 1fr 1fr;
851
+ gap: 12px;
852
+ margin: 14px 0;
853
+ }
854
+ @media (max-width: 720px) {
855
+ .cd-grid {
856
+ grid-template-columns: 1fr;
857
+ }
858
+ }
859
+ .cd-box {
860
+ background: var(--bg-raised);
861
+ border: 1px solid var(--border);
862
+ border-radius: var(--radius);
863
+ padding: 14px 16px;
864
+ }
865
+ .cd-box-title {
866
+ font-size: 12px;
867
+ font-weight: 700;
868
+ text-transform: uppercase;
869
+ letter-spacing: 0.04em;
870
+ color: var(--text-muted);
871
+ margin-bottom: 8px;
872
+ }
873
+ .cd-box-title.cd-good {
874
+ color: var(--ok);
875
+ }
876
+ .cd-list {
877
+ margin: 0;
878
+ padding-left: 18px;
879
+ font-size: 13px;
880
+ line-height: 1.65;
881
+ color: var(--text-muted);
882
+ }
883
+ .cd-list li {
884
+ margin-bottom: 4px;
885
+ }
886
+ .cd-control {
887
+ display: flex;
888
+ align-items: center;
889
+ gap: 14px;
890
+ padding: 12px 0 4px;
891
+ }
892
+ .cd-cli {
893
+ margin-top: 12px;
894
+ font-size: 12px;
895
+ color: var(--text-muted);
896
+ }
897
+ /* Copy-paste command line */
898
+ .cmd-box {
899
+ display: flex;
900
+ align-items: center;
901
+ gap: 8px;
902
+ margin-top: 6px;
903
+ background: var(--bg-raised);
904
+ border: 1px solid var(--border);
905
+ border-radius: var(--radius);
906
+ padding: 8px 10px;
907
+ }
908
+ .cmd-box code {
909
+ flex: 1;
910
+ font-family: 'JetBrains Mono', 'SF Mono', monospace;
911
+ font-size: 12px;
912
+ color: var(--text-primary);
913
+ white-space: pre-wrap;
914
+ word-break: break-all;
915
+ }
916
+ .cmd-copy {
917
+ flex-shrink: 0;
918
+ background: transparent;
919
+ border: 1px solid var(--border);
920
+ color: var(--sage);
921
+ border-radius: 6px;
922
+ padding: 4px 10px;
923
+ font-size: 11px;
924
+ font-weight: 600;
925
+ cursor: pointer;
926
+ transition:
927
+ border-color 0.15s,
928
+ color 0.15s;
929
+ }
930
+ .cmd-copy:hover {
931
+ border-color: var(--sage);
932
+ }
933
+ .cmd-copy.copied {
934
+ color: var(--ok);
935
+ border-color: var(--ok);
936
+ }
937
+
938
+ /* Optional alert-channel disclosure */
939
+ .notify-summary {
940
+ cursor: pointer;
941
+ font-size: 13px;
942
+ font-weight: 600;
943
+ color: var(--sage);
944
+ list-style: none;
945
+ user-select: none;
946
+ padding: 4px 0;
947
+ }
948
+ .notify-summary::-webkit-details-marker {
949
+ display: none;
950
+ }
951
+ .notify-summary::before {
952
+ content: '▸ ';
953
+ color: var(--text-muted);
954
+ }
955
+ details[open] .notify-summary::before {
956
+ content: '▾ ';
957
+ }
958
+ .nc {
959
+ background: var(--bg-raised);
960
+ border: 1px solid var(--border);
961
+ border-radius: var(--radius);
962
+ padding: 12px 14px;
963
+ }
964
+ .nc-head {
965
+ display: flex;
966
+ align-items: baseline;
967
+ gap: 8px;
968
+ margin-bottom: 4px;
969
+ }
970
+ .nc-name {
971
+ font-size: 13px;
972
+ font-weight: 700;
973
+ color: var(--text-primary);
974
+ }
975
+ .nc-state {
976
+ font-size: 11px;
977
+ font-weight: 600;
978
+ }
979
+ .nc-what {
980
+ font-size: 12px;
981
+ color: var(--text-muted);
982
+ line-height: 1.55;
983
+ margin-bottom: 8px;
984
+ }
985
+
609
986
  /* Spinner */
610
987
  .spin {
611
988
  display: inline-block;
@@ -791,29 +1168,138 @@
791
1168
  color: var(--ok);
792
1169
  }
793
1170
 
794
- /* Tier-upsell link (shown when license tier is community for paid features) */
795
- .upsell {
796
- display: inline-flex;
797
- align-items: center;
798
- gap: 6px;
799
- font-size: 12px;
800
- color: var(--text-muted);
801
- text-decoration: none;
802
- padding: 6px 10px;
803
- border-radius: 8px;
804
- border: 1px dashed var(--border);
805
- transition: all 0.15s;
1171
+ /* Neutral default + connecting state for the persistent status dot, so it
1172
+ never reads green before a real armed status loads. */
1173
+ .pbar .pb-dot {
1174
+ background: var(--text-muted);
806
1175
  }
807
- .upsell:hover {
808
- color: var(--sage);
809
- border-color: var(--sage);
1176
+ .pbar .pb-dot.warn {
1177
+ background: var(--warn);
1178
+ box-shadow: 0 0 6px rgba(251, 191, 36, 0.4);
810
1179
  }
811
1180
 
812
- /* Threat Cloud flywheel */
813
- .flywheel {
1181
+ /* Connection guidance banner: shown only when Guard is unreachable. The
1182
+ single honest answer to "why is everything blank?" with the one fix. */
1183
+ .conn-banner {
1184
+ display: flex;
1185
+ align-items: center;
1186
+ gap: 12px;
1187
+ flex-wrap: wrap;
1188
+ padding: 12px 16px;
1189
+ margin-bottom: 16px;
1190
+ border: 1px solid var(--danger);
1191
+ border-radius: 10px;
1192
+ background: rgba(239, 68, 68, 0.08);
1193
+ font-size: 13px;
1194
+ line-height: 1.5;
1195
+ color: var(--text-primary);
1196
+ }
1197
+ .conn-banner .cb-text {
1198
+ flex: 1;
1199
+ min-width: 200px;
1200
+ }
1201
+ .conn-banner .conn-cmd {
814
1202
  font-family: 'JetBrains Mono', 'SF Mono', monospace;
815
- font-size: 12px;
816
- color: var(--text-muted);
1203
+ font-size: 13px;
1204
+ background: var(--bg-base);
1205
+ border: 1px solid var(--border);
1206
+ border-radius: 6px;
1207
+ padding: 4px 10px;
1208
+ color: var(--emerald);
1209
+ }
1210
+
1211
+ /* One fused posture hero: state (left) + score with one-click fixes
1212
+ (right), collapsing to one column on narrow screens. */
1213
+ .hero-fused {
1214
+ display: grid;
1215
+ grid-template-columns: 1fr auto;
1216
+ grid-template-areas: 'main score' 'fixes fixes';
1217
+ gap: 18px 32px;
1218
+ align-items: start;
1219
+ }
1220
+ .hp-main {
1221
+ grid-area: main;
1222
+ min-width: 0;
1223
+ }
1224
+ .hp-score {
1225
+ grid-area: score;
1226
+ text-align: center;
1227
+ min-width: 140px;
1228
+ }
1229
+ .hp-fixes {
1230
+ grid-area: fixes;
1231
+ display: flex;
1232
+ flex-direction: column;
1233
+ gap: 8px;
1234
+ }
1235
+ .hp-fixes:empty {
1236
+ display: none;
1237
+ }
1238
+ .hp-state {
1239
+ display: flex;
1240
+ align-items: center;
1241
+ gap: 12px;
1242
+ flex-wrap: wrap;
1243
+ }
1244
+ .hp-sub {
1245
+ font-size: 14px;
1246
+ color: var(--text-primary);
1247
+ line-height: 1.6;
1248
+ margin-top: 10px;
1249
+ max-width: 56ch;
1250
+ }
1251
+ .hp-tagline {
1252
+ font-size: 12px;
1253
+ color: var(--text-muted);
1254
+ margin-top: 14px;
1255
+ letter-spacing: 0.2px;
1256
+ }
1257
+ .score-num {
1258
+ font-family: 'Space Grotesk', sans-serif;
1259
+ font-size: 56px;
1260
+ font-weight: 700;
1261
+ line-height: 1;
1262
+ color: var(--emerald);
1263
+ }
1264
+ .score-cap {
1265
+ font-size: 11px;
1266
+ letter-spacing: 1px;
1267
+ color: var(--text-muted);
1268
+ text-transform: uppercase;
1269
+ margin-top: 4px;
1270
+ }
1271
+ .score-track {
1272
+ height: 8px;
1273
+ border-radius: 6px;
1274
+ background: var(--bg-raised);
1275
+ overflow: hidden;
1276
+ margin: 12px 0 6px;
1277
+ }
1278
+ .score-fill {
1279
+ height: 100%;
1280
+ width: 0;
1281
+ background: var(--emerald);
1282
+ transition: width 0.4s;
1283
+ }
1284
+ .score-label {
1285
+ font-size: 12px;
1286
+ color: var(--text-muted);
1287
+ }
1288
+ @media (max-width: 720px) {
1289
+ .hero-fused {
1290
+ grid-template-columns: 1fr;
1291
+ grid-template-areas: 'score' 'main' 'fixes';
1292
+ }
1293
+ .hp-score {
1294
+ text-align: left;
1295
+ }
1296
+ }
1297
+
1298
+ /* Threat Cloud flywheel */
1299
+ .flywheel {
1300
+ font-family: 'JetBrains Mono', 'SF Mono', monospace;
1301
+ font-size: 12px;
1302
+ color: var(--text-muted);
817
1303
  background: var(--bg-raised);
818
1304
  border: 1px solid var(--border);
819
1305
  border-radius: var(--radius);
@@ -828,13 +1314,36 @@
828
1314
  <!-- Sidebar (220px fixed) -->
829
1315
  <div class="sb">
830
1316
  <div class="sb-brand">
831
- <svg viewBox="385 323 1278 1403" fill="none">
1317
+ <!-- Official PanGuard brand mark — the full 5-path 3D cube-shield (three
1318
+ interlocking shield faces + top bracket), matching packages/website
1319
+ BrandLogo.tsx. The dashboard previously shipped only one of the five
1320
+ paths, so it rendered as a flat blob. fg = sage, bg = sidebar cutout. -->
1321
+ <svg viewBox="385 323 1278 1403" fill="none" aria-label="PanGuard AI">
832
1322
  <path
833
- fill="#8B9A8E"
1323
+ fill="var(--sage)"
834
1324
  d="M 1021.5 830.423 C 1026.54 829.911 1045.81 832.659 1051.64 833.401 C 1072.38 835.99 1093.1 838.746 1113.8 841.667 L 1329.99 871.428 L 1329.95 1306.51 L 1330 1422.21 C 1330.01 1450.54 1332.08 1491.81 1325.18 1518.04 C 1318.79 1541.46 1305.62 1562.47 1287.31 1578.42 C 1269.98 1593.59 1234.62 1610.78 1212.96 1622.97 C 1151.4 1657.6 1087.44 1689.5 1026.42 1725.08 L 1024.58 1725.72 C 1020.72 1724.57 1005.67 1715.74 1001.44 1713.38 C 986.676 1705.08 971.857 1696.88 956.982 1688.79 L 836.342 1623.07 C 818.678 1613.37 800.537 1603.48 783.285 1593.93 C 741.183 1570.62 718.225 1528.6 718.162 1480.8 C 718.14 1463.85 718.093 1446.28 718.119 1429.07 L 718.13 1313.33 L 718.15 871.408 C 819.171 858.462 920.068 841.835 1021.5 830.423 z"
835
1325
  />
1326
+ <path
1327
+ fill="var(--bg-card)"
1328
+ d="M 1022.52 931.436 C 1027.43 930.745 1072.31 937.152 1079.51 938.107 C 1129.15 944.531 1178.76 951.264 1228.32 958.306 L 1228.23 1353.88 L 1228.35 1443.73 C 1228.37 1464.29 1233.5 1493.24 1214.51 1505.36 C 1195.74 1517.34 1175.98 1527.71 1156.43 1538.41 C 1112.57 1561.92 1068.87 1585.75 1025.35 1609.89 L 1024.59 1610.05 C 1019.31 1608.51 1009.91 1602.52 1004.79 1599.72 C 950.803 1570.14 896.481 1541.36 842.686 1511.4 C 829.113 1503.85 820.756 1496.4 820.195 1479.81 C 819.565 1461.16 819.816 1442.57 819.824 1423.9 L 819.867 1314.91 L 819.664 958.376 C 837.727 956.927 860.873 952.653 879.255 950.388 C 926.512 944.567 975.292 936.455 1022.52 931.436 z"
1329
+ />
1330
+ <path
1331
+ fill="var(--sage)"
1332
+ d="M 687.515 577.4 C 696.504 576.451 715.718 579.584 725.68 580.913 L 782.266 588.553 C 853.62 598.25 926.853 607.634 997.939 618.387 L 997.872 779.654 C 966.188 784.487 928.734 789.954 897.039 793.352 L 897.073 705.399 C 831.524 696.853 760.432 685.286 694.176 678.356 C 683.151 677.203 646.195 683.753 632.643 685.485 L 487.112 705.406 C 488.77 841.757 487.098 980.17 487.357 1116.69 L 487.339 1188.98 C 487.213 1243.94 479.328 1246.69 530.666 1273.69 C 574.625 1298.03 623.372 1322.43 666.093 1347.35 C 665.454 1385.26 666.06 1424.82 666.087 1462.87 C 649.609 1452.6 628.321 1441.62 610.88 1432.12 L 500.871 1372.76 C 476.603 1359.56 440.159 1342.09 421.535 1323.57 C 404.11 1306.02 392.433 1283.59 388.055 1259.25 C 384.254 1237.81 385.323 1203.28 385.354 1180.66 L 385.478 1064.48 L 385.312 618.209 C 414.868 615.376 445.833 609.518 475.437 605.863 C 545.54 597.208 617.46 584.737 687.515 577.4 z"
1333
+ />
1334
+ <path
1335
+ fill="var(--sage)"
1336
+ d="M 1348.89 577.369 C 1360.73 575.936 1415.83 584.528 1431.77 586.589 C 1508.75 596.536 1585.62 608.685 1662.67 618.335 L 1662.73 1028.87 L 1662.69 1154.09 C 1662.67 1223.3 1674.33 1293.75 1609.64 1337.92 C 1596.01 1347.22 1581.75 1354.57 1567.33 1362.44 L 1512.89 1391.87 L 1382 1462.6 C 1383.76 1427.96 1382.35 1382.83 1382.21 1347.24 C 1420 1324.81 1467.93 1301.58 1507.58 1279.5 C 1524.53 1270.36 1558.74 1256.3 1560.1 1235.39 C 1561.42 1215.18 1560.95 1193.69 1560.9 1173.32 L 1560.87 1066.29 L 1560.68 705.345 C 1543.39 703.65 1521.51 700.068 1504.1 697.576 C 1473.23 693.054 1442.33 688.801 1411.39 684.819 C 1399.03 683.204 1369.09 678.563 1357.99 678.482 C 1343.65 678.377 1310.02 683.839 1294.14 685.904 C 1246.36 692.092 1198.63 698.627 1150.95 705.51 C 1151.37 734.567 1151.1 764.255 1151.14 793.361 C 1118.48 789.558 1083.43 784.206 1050.79 779.597 L 1050.69 617.847 L 1348.89 577.369 z"
1337
+ />
1338
+ <path
1339
+ fill="var(--sage)"
1340
+ d="M 1017.32 323.354 C 1027.15 321.591 1105.66 333.344 1122.51 335.603 C 1166.74 341.267 1210.92 347.328 1255.05 353.786 C 1279.68 357.183 1305.51 360.23 1329.92 364.192 L 1329.98 526.684 C 1296.55 530.372 1261.73 535.721 1228.25 540.29 C 1228.4 510.591 1228.39 480.892 1228.23 451.193 C 1219.27 449.729 1209.61 448.427 1200.57 447.329 C 1144.13 440.467 1086.78 429.864 1030.24 424.572 C 1018.71 422.967 967.642 430.978 952.027 433.125 L 819.579 450.968 C 820.252 479.945 819.726 511.22 819.879 540.382 C 785.991 535.397 752.051 530.778 718.064 526.526 L 717.99 364.358 C 745.543 359.872 774.642 356.332 802.417 352.399 L 1017.32 323.354 z"
1341
+ />
836
1342
  </svg>
837
- <span>PANGUARD AI</span>
1343
+ <div class="sb-brand-text">
1344
+ <span class="sb-wordmark">PanGuard</span>
1345
+ <span class="brand-tier" id="brand-tier">Community</span>
1346
+ </div>
838
1347
  </div>
839
1348
  <div class="sb-nav">
840
1349
  <div class="ni on" data-tab="overview">
@@ -861,6 +1370,20 @@
861
1370
  /></svg
862
1371
  ><span data-i18n="n_sk">Skills</span>
863
1372
  </div>
1373
+ <div class="ni" data-tab="coverage">
1374
+ <svg viewBox="0 0 24 24">
1375
+ <path
1376
+ d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
1377
+ /></svg
1378
+ ><span>Coverage</span>
1379
+ </div>
1380
+ <div class="ni" data-tab="runtime">
1381
+ <svg viewBox="0 0 24 24">
1382
+ <path
1383
+ d="M13 3l-2 0 0 10 2 0 0-10zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12a7 7 0 11-11.41-5.42L6.17 5.17A9 9 0 1021 12a8.94 8.94 0 00-3.17-6.83z"
1384
+ /></svg
1385
+ ><span>Runtime</span>
1386
+ </div>
864
1387
  <div class="ni" data-tab="tcloud">
865
1388
  <svg viewBox="0 0 24 24">
866
1389
  <path
@@ -894,6 +1417,15 @@
894
1417
  >COMMUNITY</span
895
1418
  >
896
1419
  </div>
1420
+ <div class="ws-b" style="margin-top: 8px">
1421
+ <span
1422
+ id="welcome-reopen"
1423
+ role="button"
1424
+ tabindex="0"
1425
+ style="font-size: 11px; color: var(--sage); cursor: pointer"
1426
+ >Show welcome guide</span
1427
+ >
1428
+ </div>
897
1429
  </div>
898
1430
  </div>
899
1431
 
@@ -901,44 +1433,156 @@
901
1433
  <div class="mn">
902
1434
  <!-- Protection status bar (always visible) -->
903
1435
  <div class="pbar" id="pbar">
904
- <div class="pb-dot ok" id="pb-dot"></div>
905
- <span class="pb-txt" id="pb-txt">CONNECTING…</span>
906
- <span id="pb-detail">| -- rules active | Uptime: --</span>
1436
+ <div class="pb-dot" id="pb-dot"></div>
1437
+ <span class="pb-txt" id="pb-txt">Connecting…</span>
1438
+ <span id="pb-detail">checking protection status…</span>
1439
+ </div>
1440
+
1441
+ <!-- Connection guidance: hidden unless Guard is unreachable. Replaces the
1442
+ old dead end where everything sat on a stale green "CONNECTING…". -->
1443
+ <div class="conn-banner" id="conn-banner" style="display: none">
1444
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1445
+ <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
1446
+ <line x1="12" y1="9" x2="12" y2="13" />
1447
+ <line x1="12" y1="17" x2="12.01" y2="17" />
1448
+ </svg>
1449
+ <div class="cb-text">
1450
+ <strong>Guard isn't running on this machine.</strong>
1451
+ Your agents aren't being watched right now. Start protection:
1452
+ </div>
1453
+ <code class="conn-cmd">pga up</code>
1454
+ <button type="button" class="btn btn-secondary btn-sm" id="conn-copy">Copy</button>
907
1455
  </div>
908
1456
 
909
1457
  <!-- ===== PAGE 1: Overview ===== -->
910
1458
  <div class="pg on" id="p-overview">
911
1459
  <div class="pt"><em data-i18n="t_ov">Overview</em></div>
912
1460
 
913
- <!-- Status hero -->
914
- <div class="hero">
915
- <div class="hero-head">
916
- <div class="live-dot" id="hero-dot"></div>
917
- <div class="hero-status" id="hero-status">CONNECTING…</div>
918
- <span class="badge badge-protected" id="hero-badge" data-i18n="active">Active</span>
1461
+ <!-- First-run welcome guide: positive first-contact orientation. Hidden by
1462
+ default; shown ONCE on the first LIVE authenticated status (gated by a
1463
+ localStorage flag + the single-source-of-truth live signal, so it can
1464
+ never paint on top of the "Guard not running" banner or the 401 overlay).
1465
+ Dismissible, never nags; reopenable from the sidebar. -->
1466
+ <div
1467
+ class="welcome"
1468
+ id="welcome-guide"
1469
+ style="display: none"
1470
+ role="region"
1471
+ aria-label="Welcome to PanGuard Guard"
1472
+ >
1473
+ <button type="button" class="welcome-x" id="welcome-x" aria-label="Close welcome guide">
1474
+ &times;
1475
+ </button>
1476
+ <div class="welcome-head">
1477
+ <span class="dot dot-ok"></span>
1478
+ <span class="welcome-title">You're protected. Here's the lay of the land.</span>
919
1479
  </div>
920
- <div
921
- style="font-size: 13px; color: var(--text-muted); line-height: 1.6"
922
- id="hero-desc"
923
- >
924
- Every event runs through deterministic pattern matching and heuristic correlation
925
- on-device by default. An optional semantic layer (bring your own LLM) stays off until
926
- you enable it.
1480
+ <div class="welcome-lead">
1481
+ PanGuard Guard is watching the AI agents on this machine. Two detection layers run
1482
+ automatically and need no setup: <strong>Layer A</strong> (proven ATR pattern rules) and
1483
+ <strong>Layer B</strong> (behavioural heuristics). They run fully on-device — nothing
1484
+ about your activity leaves this machine. Everything below is here only when you want it.
1485
+ </div>
1486
+
1487
+ <div class="welcome-grid">
1488
+ <div class="welcome-col">
1489
+ <div class="welcome-col-h">Where things are</div>
1490
+ <ul class="welcome-list">
1491
+ <li><strong>Overview</strong> — am I safe right now? (this page)</li>
1492
+ <li><strong>Threats</strong> — anything Guard has caught</li>
1493
+ <li><strong>Coverage</strong> — which agent apps are protected</li>
1494
+ <li><strong>Rules · Skills · Runtime</strong> — reference, not chores</li>
1495
+ <li><strong>Settings</strong> — mode, the optional model, alerts</li>
1496
+ </ul>
1497
+ </div>
1498
+ <div class="welcome-col">
1499
+ <div class="welcome-col-h">
1500
+ Optional next steps <span class="welcome-opt">— both off by default</span>
1501
+ </div>
1502
+
1503
+ <div class="welcome-step">
1504
+ <div class="welcome-step-h">Add a second opinion (Layer C)</div>
1505
+ <div class="welcome-step-b">
1506
+ Optionally connect your own LLM for an advisory read on anything Layers A and B
1507
+ flag. It only ever <strong>flags for review — it can never block on its own</strong>.
1508
+ Runs on already-flagged events only, so a cloud key is a few cents a month; a local
1509
+ model (Ollama) is free. <strong>Your key is entered in the terminal, never here</strong>,
1510
+ and is stored encrypted on this machine.
1511
+ </div>
1512
+ <div class="cmd-box">
1513
+ <code id="welcome-ai-cmd">pga guard ai</code>
1514
+ <button class="cmd-copy" data-copy-target="welcome-ai-cmd" type="button">
1515
+ Copy
1516
+ </button>
1517
+ </div>
1518
+ </div>
1519
+
1520
+ <div class="welcome-step">
1521
+ <div class="welcome-step-h">Join collective defense</div>
1522
+ <div class="welcome-step-b">
1523
+ Optionally let the attacks Guard blocks here help draft new open rules for everyone —
1524
+ and get the community's new rules back sooner. Shares only a rule ID and a one-way
1525
+ hash; <strong>never your prompts, code, files, keys, IP, or hostname</strong>. Off
1526
+ until you turn it on.
1527
+ </div>
1528
+ <button type="button" class="btn btn-secondary btn-sm" data-goto="tcloud">
1529
+ Open Threat Cloud &rarr;
1530
+ </button>
1531
+ </div>
1532
+ </div>
927
1533
  </div>
1534
+
1535
+ <div class="welcome-foot">
1536
+ <button type="button" class="btn btn-primary btn-sm" id="welcome-done">Got it</button>
1537
+ <button type="button" class="welcome-later" id="welcome-later">Dismiss for now</button>
1538
+ <span class="welcome-foot-note">
1539
+ <code>pga</code> commands run in your terminal. Reopen this anytime from the sidebar.
1540
+ </span>
1541
+ </div>
1542
+ </div>
1543
+
1544
+ <!-- One fused posture hero: the single answer to "am I safe right now?".
1545
+ Left = honest state + a plain-English line; right = the posture score
1546
+ with one-click fixes. Starts NEUTRAL (never a static green "Active");
1547
+ setConnState()/updateStatus() drive every element from one source of
1548
+ truth, and a 401 swaps in the unauthenticated screen instead. All IDs
1549
+ the JS drives are preserved (DESIGN.md §2.1: lead with one decision). -->
1550
+ <div class="hero hero-fused" id="posture-card">
1551
+ <div class="hp-main">
1552
+ <div class="hp-state">
1553
+ <span class="live-dot" id="hero-dot"></span>
1554
+ <span class="hero-status" id="hero-status">Connecting…</span>
1555
+ <span class="badge badge-off" id="hero-badge">Connecting…</span>
1556
+ </div>
1557
+ <div class="hp-sub" id="hero-desc">Checking Guard status on this machine…</div>
1558
+ <div class="hp-tagline">
1559
+ PanGuard Community — free, on-device protection for your AI agents.
1560
+ </div>
1561
+ </div>
1562
+ <div class="hp-score">
1563
+ <div class="score-num" id="posture-score">--</div>
1564
+ <div class="score-cap">Posture</div>
1565
+ <div class="score-track"><div class="score-fill" id="posture-bar"></div></div>
1566
+ <div class="score-label" id="posture-label">Checking…</div>
1567
+ </div>
1568
+ <div class="hp-fixes" id="posture-deductions"></div>
928
1569
  </div>
929
1570
 
930
1571
  <!-- 4 KPI cards -->
931
1572
  <div class="cg">
932
- <div class="cd">
1573
+ <div class="cd" title="Total ATR detection rules currently loaded and enforcing on this machine.">
933
1574
  <div class="cl" data-i18n="k_rules">Rules Active</div>
934
1575
  <div class="cv sg" id="v-rules">--</div>
935
1576
  </div>
936
- <div class="cd">
1577
+ <div class="cd" title="Threats Guard has detected on this machine.">
937
1578
  <div class="cl" data-i18n="k_threats">Threats Detected</div>
938
1579
  <div class="cv sg" id="v-threats">0</div>
939
1580
  </div>
940
- <div class="cd">
941
- <div class="cl" data-i18n="k_tc">TC Rules Synced</div>
1581
+ <div
1582
+ class="cd"
1583
+ title="Detection rules pulled from the Threat Cloud network, included in your active rule set."
1584
+ >
1585
+ <div class="cl" data-i18n="k_tc">Threat Cloud rules synced</div>
942
1586
  <div class="cv sg" id="v-tcsync">0</div>
943
1587
  </div>
944
1588
  </div>
@@ -946,12 +1590,15 @@
946
1590
  <!-- Detection layers status (renamed Tier → Layer A/B/C) -->
947
1591
  <div class="fs">
948
1592
  <h3>
949
- <span class="dot dot-ok"></span> <span data-i18n="layer_title">Detection Layers</span>
1593
+ <span class="dot dot-ok" id="layers-dot"></span>
1594
+ <span data-i18n="layer_title">Detection Layers</span>
950
1595
  </h3>
951
1596
  <div class="desc">
952
- Layers A and B are deterministic and always on. Layer C is optional — bring your own
953
- LLM (cloud API or local model). It is advisory (flags for review, never auto-blocks)
954
- and stays off until you connect one.
1597
+ Layers A and B are deterministic and always on. Layer C is optional — bring your own LLM
1598
+ (cloud API or local model). It is advisory (flags for review, never auto-blocks) and
1599
+ stays off until you connect one. It runs only on events A and B already flagged, not on
1600
+ every action, so cloud use is typically a few cents a month — and a local model (Ollama)
1601
+ is free and keeps everything on your machine.
955
1602
  </div>
956
1603
  <div class="layer-row">
957
1604
  <span class="layer-label">Layer A &mdash; Pattern match (ATR rules)</span>
@@ -964,10 +1611,45 @@
964
1611
  <span class="layer-status on" id="lb-st">Active</span>
965
1612
  </div>
966
1613
  <div class="layer-row">
967
- <span class="layer-label">Layer C &mdash; Semantic verdict (optional &middot; advisory)</span>
1614
+ <span class="layer-label"
1615
+ >Layer C &mdash; Semantic verdict (optional &middot; advisory)</span
1616
+ >
968
1617
  <div class="layer-bar"><div class="layer-fill" id="lc-bar"></div></div>
969
1618
  <span class="layer-status" id="lc-st">Off &middot; bring your own</span>
970
1619
  </div>
1620
+ <!-- Layer C call-to-action: a discoverable two-state affordance that
1621
+ replaces the old silent server-filled hint. Not connected -> why +
1622
+ "Set up Layer C" deep-linking to the Settings section. Connected ->
1623
+ a calm confirmation. Driven from status.layers.c.state. -->
1624
+ <div
1625
+ class="lc-cta"
1626
+ id="lc-cta"
1627
+ style="
1628
+ margin-top: 10px;
1629
+ display: flex;
1630
+ align-items: center;
1631
+ justify-content: space-between;
1632
+ gap: 12px;
1633
+ flex-wrap: wrap;
1634
+ padding: 10px 12px;
1635
+ background: var(--bg-raised);
1636
+ border: 1px solid var(--border);
1637
+ border-radius: 10px;
1638
+ "
1639
+ >
1640
+ <div
1641
+ class="lc-cta-text"
1642
+ id="lc-cta-text"
1643
+ style="font-size: 12px; color: var(--text-muted); line-height: 1.6; flex: 1; min-width: 220px"
1644
+ >
1645
+ Optional: connect your own LLM for a second opinion on flagged events — catches novel
1646
+ attacks A and B miss. Advisory only: it flags for review, never auto-blocks. Free with a
1647
+ local model, or a few cents a month with a cloud key.
1648
+ </div>
1649
+ <button type="button" class="btn btn-secondary btn-sm" id="lc-cta-btn">
1650
+ Set up Layer C
1651
+ </button>
1652
+ </div>
971
1653
  </div>
972
1654
 
973
1655
  <!-- Uptime counter -->
@@ -1017,69 +1699,43 @@
1017
1699
  "
1018
1700
  >
1019
1701
  <div class="flt">
1020
- <button
1021
- class="fb on"
1022
- data-sev="all"
1023
- data-i18n="all"
1024
- >
1025
- All
1026
- </button>
1027
- <button
1028
- class="fb"
1029
- data-sev="critical"
1030
- data-i18n="critical"
1031
- >
1032
- Critical
1033
- </button>
1034
- <button
1035
- class="fb"
1036
- data-sev="high"
1037
- data-i18n="high"
1038
- >
1039
- High
1040
- </button>
1041
- <button
1042
- class="fb"
1043
- data-sev="medium"
1044
- data-i18n="medium"
1045
- >
1046
- Medium
1047
- </button>
1048
- <button class="fb" data-sev="low" data-i18n="low">
1049
- Low
1050
- </button>
1702
+ <button class="fb on" data-sev="all" data-i18n="all">All</button>
1703
+ <button class="fb" data-sev="critical" data-i18n="critical">Critical</button>
1704
+ <button class="fb" data-sev="high" data-i18n="high">High</button>
1705
+ <button class="fb" data-sev="medium" data-i18n="medium">Medium</button>
1706
+ <button class="fb" data-sev="low" data-i18n="low">Low</button>
1051
1707
  </div>
1052
1708
  <div style="display: flex; gap: 8px; align-items: center">
1053
1709
  <button
1054
1710
  class="btn btn-secondary btn-sm"
1055
- data-tier-gate="pilot"
1056
1711
  id="btn-sarif"
1057
- style="display: none"
1712
+ title="Download a .sarif.json of detected threats (the standard format GitHub and code scanners read). Saved to your computer — nothing is uploaded, and it never includes your source code."
1058
1713
  >
1059
1714
  <span data-i18n="exp_sarif">Export SARIF</span>
1060
1715
  </button>
1061
1716
  <button
1062
1717
  class="btn btn-primary btn-sm"
1063
- data-tier-gate="pilot"
1064
1718
  id="btn-evidence"
1065
- style="display: none"
1719
+ title="Download a JSON evidence report of detections (rule IDs, timestamps, severities, matched patterns). Saved to your computer — nothing is uploaded, and it never includes your source code."
1066
1720
  >
1067
1721
  <span data-i18n="exp_evidence">Generate Evidence Pack</span>
1068
1722
  </button>
1069
- <a
1070
- class="upsell"
1071
- data-tier-upsell="pilot"
1072
- href="https://panguard.ai/pricing"
1073
- target="_blank"
1074
- rel="noopener"
1075
- style="display: none"
1076
- data-i18n="upsell_pilot"
1077
- >
1078
- Upgrade to Pilot &mdash; unlock SARIF + Evidence Pack
1079
- </a>
1080
1723
  </div>
1081
1724
  </div>
1082
1725
 
1726
+ <div class="pd" style="margin: -6px 0 14px; font-size: 12px">
1727
+ Export your detected threats as a file you can keep or share. SARIF is a standard format
1728
+ GitHub and code scanners read; the Evidence Pack is a self-contained JSON report. Both
1729
+ download to your computer — nothing is uploaded, and neither includes your source code or
1730
+ file contents.
1731
+ </div>
1732
+
1733
+ <!-- Suppression summary (anti alert-fatigue: only high-confidence shown by default) -->
1734
+ <div
1735
+ id="th-summary"
1736
+ style="display: none; font-size: 12px; color: var(--text-secondary); margin: 4px 0 10px"
1737
+ ></div>
1738
+
1083
1739
  <!-- Sortable threat table -->
1084
1740
  <table class="tb">
1085
1741
  <thead>
@@ -1087,6 +1743,7 @@
1087
1743
  <th data-sort="timestamp" class="sortable">Timestamp</th>
1088
1744
  <th data-sort="ruleId" class="sortable">Rule ID</th>
1089
1745
  <th data-sort="severity" class="sortable">Severity</th>
1746
+ <th data-sort="confidence" class="sortable">Confidence</th>
1090
1747
  <th>Matched Pattern</th>
1091
1748
  <th data-sort="action" class="sortable">Action</th>
1092
1749
  </tr>
@@ -1105,10 +1762,28 @@
1105
1762
  <div class="pg" id="p-rules">
1106
1763
  <div class="pt"><em data-i18n="t_ru">Rules</em></div>
1107
1764
  <div class="pd" data-i18n="d_ru">
1108
- All loaded ATR detection rules. Rules sync hourly from Threat Cloud. Use search and
1109
- filters to find specific rules.
1765
+ All loaded ATR detection rules. The bundled rule set is checked daily against npm; updates
1766
+ are surfaced below and applied only when you run <code>pga upgrade</code> — rules are the
1767
+ engine's trust root and are never auto-pulled. Use search and filters to find specific
1768
+ rules.
1110
1769
  </div>
1111
1770
 
1771
+ <!-- Rule-update banner: surfaces rule-sync's daily notify-only check.
1772
+ Hidden unless a newer published rule set is available. -->
1773
+ <div
1774
+ id="rule-update-banner"
1775
+ style="
1776
+ display: none;
1777
+ margin: 0 0 16px;
1778
+ padding: 12px 16px;
1779
+ border: 1px solid var(--emerald, #34d399);
1780
+ border-radius: 10px;
1781
+ background: rgba(52, 211, 153, 0.08);
1782
+ font-size: 13px;
1783
+ line-height: 1.6;
1784
+ "
1785
+ ></div>
1786
+
1112
1787
  <div class="cg">
1113
1788
  <div class="cd">
1114
1789
  <div class="cl" data-i18n="ru_total">Total Rules</div>
@@ -1255,18 +1930,105 @@
1255
1930
  <th data-i18n="sk_name">Name</th>
1256
1931
  <th data-i18n="sk_platform">Platform</th>
1257
1932
  <th data-i18n="sk_status">Status</th>
1933
+ <th data-i18n="sk_actions">Actions</th>
1258
1934
  </tr>
1259
1935
  </thead>
1260
1936
  <tbody id="sk-tb"></tbody>
1261
1937
  </table>
1938
+
1939
+ <!-- Quarantined files — see what's quarantined and restore it. The
1940
+ quarantine confirm dialog promises restorability; this honors it.
1941
+ Hidden when nothing is quarantined. -->
1942
+ <div id="qz-section" style="display: none; margin-top: 28px">
1943
+ <div class="st" style="margin-bottom: 12px">Quarantined Files</div>
1944
+ <table class="tb">
1945
+ <thead>
1946
+ <tr>
1947
+ <th>Original path</th>
1948
+ <th>Reason</th>
1949
+ <th>Quarantined</th>
1950
+ <th>Actions</th>
1951
+ </tr>
1952
+ </thead>
1953
+ <tbody id="qz-tb"></tbody>
1954
+ </table>
1955
+ </div>
1956
+ </div>
1957
+
1958
+ <!-- ===== Coverage ===== -->
1959
+ <div class="pg" id="p-coverage">
1960
+ <div class="pt"><em>Coverage</em></div>
1961
+ <div class="pd">
1962
+ Which AI agent platforms Guard can see on this machine, and whether each is wired up. A
1963
+ detected-but-not-connected platform is running <strong>unprotected</strong> — connect it
1964
+ with <code>pga init</code>.
1965
+ </div>
1966
+ <div class="cg">
1967
+ <div class="cd">
1968
+ <div class="cl">Detected</div>
1969
+ <div class="cv sg" id="cov-detected">--</div>
1970
+ </div>
1971
+ <div class="cd">
1972
+ <div class="cl">Protected</div>
1973
+ <div class="cv" id="cov-configured" style="color: var(--emerald)">--</div>
1974
+ </div>
1975
+ <div class="cd">
1976
+ <div class="cl">Unprotected</div>
1977
+ <div class="cv" id="cov-unprotected">--</div>
1978
+ </div>
1979
+ </div>
1980
+ <div id="cov-loading" class="empty">
1981
+ <span class="spin"></span> <span>Detecting platforms…</span>
1982
+ </div>
1983
+ <table class="tb" id="cov-table" style="display: none">
1984
+ <thead>
1985
+ <tr>
1986
+ <th>Platform</th>
1987
+ <th>Status</th>
1988
+ </tr>
1989
+ </thead>
1990
+ <tbody id="cov-tb"></tbody>
1991
+ </table>
1992
+ </div>
1993
+
1994
+ <!-- ===== Runtime ===== -->
1995
+ <div class="pg" id="p-runtime">
1996
+ <div class="pt"><em>Runtime</em></div>
1997
+ <div class="pd">
1998
+ Live decisions from the MCP proxy as your agents call tools. Quiet when healthy — a row
1999
+ appears only on a real evaluation. <strong>allow</strong> = passed,
2000
+ <strong>deny</strong> = blocked on a proven rule, <strong>ask</strong> = flagged for you
2001
+ to decide.
2002
+ </div>
2003
+ <div id="rt-loading" class="empty">
2004
+ <span class="spin"></span> <span>Loading runtime verdicts…</span>
2005
+ </div>
2006
+ <div id="rt-empty" class="empty" style="display: none">
2007
+ No proxy activity yet. Verdicts appear here when the MCP proxy evaluates a tool call.
2008
+ </div>
2009
+ <table class="tb" id="rt-table" style="display: none">
2010
+ <thead>
2011
+ <tr>
2012
+ <th>Time</th>
2013
+ <th>Outcome</th>
2014
+ <th>Tool / reason</th>
2015
+ <th>Confidence</th>
2016
+ </tr>
2017
+ </thead>
2018
+ <tbody id="rt-tb"></tbody>
2019
+ </table>
1262
2020
  </div>
1263
2021
 
1264
2022
  <!-- ===== PAGE 5: Threat Cloud ===== -->
1265
2023
  <div class="pg" id="p-tcloud">
1266
2024
  <div class="pt"><em data-i18n="t_tc">Threat Cloud</em></div>
1267
2025
  <div class="pd" data-i18n="d_tc">
1268
- Threat Cloud is PanGuard's anonymous threat intelligence sharing network. Your endpoint
1269
- can upload anonymized threat patterns and receive updated detection rules in return.
2026
+ Threat Cloud is PanGuard's anonymous threat-intelligence network. It is
2027
+ <strong>off by default until you turn on Collective Defense below, nothing leaves this
2028
+ machine.</strong> When you do enable it, your endpoint uploads anonymized threat signatures
2029
+ (no code, no file contents). New detection rules ship in the open ATR package — Guard
2030
+ checks once a day and tells you when an upgrade is available. It never auto-pulls rule logic
2031
+ from the network, so a compromised relay can't change what your engine enforces.
1270
2032
  </div>
1271
2033
 
1272
2034
  <div id="tc-loading" class="empty">
@@ -1316,20 +2078,54 @@
1316
2078
 
1317
2079
  <div class="st" data-i18n="tc_flywheel">Flywheel</div>
1318
2080
  <div class="flywheel" id="tc-flywheel">
1319
- <strong>Your endpoint</strong> detects threats &rarr;
1320
- <strong>Threat Cloud</strong> aggregates them anonymously &rarr;
1321
- <strong>Community</strong> rules sync back to every endpoint, hourly.
2081
+ <strong>Your endpoint</strong> detects a threat &rarr;
2082
+ <strong>Threat Cloud</strong> aggregates it anonymously &rarr; it strengthens the open
2083
+ <strong>ATR</strong> ruleset &rarr; you get the improvements through
2084
+ <strong>pga upgrade</strong> (a daily check flags when new rules are out).
1322
2085
  </div>
1323
2086
 
1324
2087
  <div class="fs" style="margin-top: 20px">
1325
- <h3 data-i18n="tc_upload">Anonymous Upload</h3>
1326
- <div class="desc" data-i18n="tc_upload_desc">
1327
- When enabled, anonymized threat patterns are uploaded to Threat Cloud. No personal
1328
- data, file contents, or source code is sent.
2088
+ <h3>Collective defense</h3>
2089
+ <div class="desc">
2090
+ Off by default. When you turn it on, every attack Guard blocks on this machine can
2091
+ become a new ATR rule that protects everyone — and you get the community's new rules
2092
+ back faster. More sensors means quicker detection for all of us.
2093
+ </div>
2094
+
2095
+ <div class="cd-grid">
2096
+ <div class="cd-box">
2097
+ <div class="cd-box-title cd-good">What turning it on does for you</div>
2098
+ <ul class="cd-list">
2099
+ <li>Your blocked patterns help draft new open ATR rules.</li>
2100
+ <li>You receive community rule updates sooner.</li>
2101
+ <li>You can turn it off again at any time.</li>
2102
+ </ul>
2103
+ </div>
2104
+ <div class="cd-box">
2105
+ <div class="cd-box-title">What is shared (and what is never shared)</div>
2106
+ <ul class="cd-list">
2107
+ <li>Shared: the matched rule ID, a one-way hash of the payload, and the source type.</li>
2108
+ <li>Never shared: your prompts, code, file contents, keys, IP address, or hostname.</li>
2109
+ <li>A random install ID only — raw samples never leave this machine.</li>
2110
+ </ul>
2111
+ </div>
1329
2112
  </div>
1330
- <div style="display: flex; align-items: center; gap: 12px">
1331
- <div class="toggle" id="tc-toggle"></div>
1332
- <span id="tc-toggle-label" style="font-size: 13px; font-weight: 600">--</span>
2113
+
2114
+ <div class="cd-control">
2115
+ <div class="toggle" id="tc-toggle" role="switch" aria-checked="false" tabindex="0"
2116
+ aria-label="Collective defense"></div>
2117
+ <div>
2118
+ <div id="tc-toggle-label" style="font-size: 14px; font-weight: 600">--</div>
2119
+ <div id="tc-toggle-sub" style="font-size: 12px; color: var(--text-muted)"></div>
2120
+ </div>
2121
+ </div>
2122
+
2123
+ <div class="cd-cli">
2124
+ Prefer the command line? This does the same thing:
2125
+ <div class="cmd-box">
2126
+ <code id="tc-cli-cmd">pga config set telemetry true</code>
2127
+ <button class="cmd-copy" data-copy-target="tc-cli-cmd" type="button">Copy</button>
2128
+ </div>
1333
2129
  </div>
1334
2130
  </div>
1335
2131
  </div>
@@ -1343,20 +2139,198 @@
1343
2139
  <div class="fs">
1344
2140
  <h3>Guard Mode</h3>
1345
2141
  <div class="desc">
1346
- Learning mode builds a baseline of normal activity and only logs. Protection mode
1347
- actively blocks detected threats. Saving a change restarts detection with the new mode.
2142
+ Three modes. <strong>Learning</strong> builds a baseline and only logs.
2143
+ <strong>Report-only</strong> detects and records everything but never acts (recommended
2144
+ for new deployments). <strong>Protection</strong> actively blocks — but only using the
2145
+ responses armed in your enforcement policy (by default that's nothing destructive: it
2146
+ detects and notifies). Saving applies to new activity right away; it won't interrupt
2147
+ actions already in progress.
1348
2148
  </div>
1349
- <div style="display: flex; align-items: center; gap: 12px">
1350
- <div class="toggle" id="st-mode-toggle"></div>
1351
- <span id="st-mode-label" style="font-size: 13px; font-weight: 600">Learning</span>
2149
+ <div
2150
+ id="st-mode-seg"
2151
+ role="radiogroup"
2152
+ aria-label="Guard mode"
2153
+ style="
2154
+ display: inline-flex;
2155
+ border: 1px solid var(--border, #2e2a27);
2156
+ border-radius: 8px;
2157
+ overflow: hidden;
2158
+ "
2159
+ >
2160
+ <button
2161
+ type="button"
2162
+ class="seg-btn"
2163
+ data-mode="learning"
2164
+ role="radio"
2165
+ aria-checked="false"
2166
+ style="
2167
+ padding: 8px 16px;
2168
+ background: transparent;
2169
+ color: var(--text-muted);
2170
+ border: 0;
2171
+ cursor: pointer;
2172
+ font-size: 13px;
2173
+ font-weight: 600;
2174
+ "
2175
+ >
2176
+ Learning
2177
+ </button>
2178
+ <button
2179
+ type="button"
2180
+ class="seg-btn"
2181
+ data-mode="report-only"
2182
+ role="radio"
2183
+ aria-checked="false"
2184
+ style="
2185
+ padding: 8px 16px;
2186
+ background: transparent;
2187
+ color: var(--text-muted);
2188
+ border: 0;
2189
+ border-left: 1px solid var(--border, #2e2a27);
2190
+ cursor: pointer;
2191
+ font-size: 13px;
2192
+ font-weight: 600;
2193
+ "
2194
+ >
2195
+ Report-only
2196
+ </button>
2197
+ <button
2198
+ type="button"
2199
+ class="seg-btn"
2200
+ data-mode="protection"
2201
+ role="radio"
2202
+ aria-checked="false"
2203
+ style="
2204
+ padding: 8px 16px;
2205
+ background: transparent;
2206
+ color: var(--text-muted);
2207
+ border: 0;
2208
+ border-left: 1px solid var(--border, #2e2a27);
2209
+ cursor: pointer;
2210
+ font-size: 13px;
2211
+ font-weight: 600;
2212
+ "
2213
+ >
2214
+ Protection
2215
+ </button>
2216
+ </div>
2217
+ <div
2218
+ id="st-mode-desc"
2219
+ style="margin-top: 8px; font-size: 12px; color: var(--text-muted); line-height: 1.6"
2220
+ ></div>
2221
+ </div>
2222
+
2223
+ <!-- ===== Semantic layer (Layer C) — optional advisory LLM =====
2224
+ The headline "how do I connect a model" surface. Self-contained (does
2225
+ NOT depend on a server string). No key field by design — the key is
2226
+ entered only in the terminal via `pga guard ai`. -->
2227
+ <div class="fs" id="p-settings-layerc">
2228
+ <h3>
2229
+ <span class="dot dot-off" id="lc-set-dot"></span>
2230
+ Semantic layer (Layer C)
2231
+ <span
2232
+ id="lc-set-state"
2233
+ style="margin-left: auto; font-size: 12px; font-weight: 600; color: var(--text-muted)"
2234
+ >Off · optional</span
2235
+ >
2236
+ </h3>
2237
+ <div class="desc">
2238
+ An optional second opinion. Layers A and B (the always-on ATR pattern rules and
2239
+ heuristics) do the blocking. Layer C adds your own LLM to read flagged events in plain
2240
+ language and catch novel attacks the deterministic layers miss. It is
2241
+ <strong>advisory only</strong> — it flags events for your review and
2242
+ <strong>can never block on its own</strong>. It runs only on events A or B already
2243
+ flagged, not on every action, so a cloud model typically costs a few cents a month, and a
2244
+ local model is free.
2245
+ </div>
2246
+
2247
+ <div class="cd-grid">
2248
+ <div class="cd-box">
2249
+ <div class="cd-box-title cd-good">Path 1 — Local model (free &amp; fully private)</div>
2250
+ <ul class="cd-list">
2251
+ <li>Runs a model on this machine with Ollama.</li>
2252
+ <li>No API key, no cost — nothing leaves your computer.</li>
2253
+ <li>Best if you want maximum privacy.</li>
2254
+ </ul>
2255
+ </div>
2256
+ <div class="cd-box">
2257
+ <div class="cd-box-title">Path 2 — Cloud model (bring your own key)</div>
2258
+ <ul class="cd-list">
2259
+ <li>Use an OpenAI or Anthropic key you already have.</li>
2260
+ <li>Typically a few cents a month (flagged events only).</li>
2261
+ <li>Your key is sent only to that provider — never to PanGuard.</li>
2262
+ </ul>
2263
+ </div>
2264
+ </div>
2265
+
2266
+ <div style="margin-top: 14px">
2267
+ <div style="font-size: 13px; font-weight: 600; margin-bottom: 6px">How to connect</div>
2268
+ <div class="desc" style="margin-bottom: 8px">
2269
+ Run this one command in your terminal. It walks you through picking a model and, for a
2270
+ cloud model, prompts for your key privately.
2271
+ <strong>You never type the key into this dashboard.</strong>
2272
+ </div>
2273
+ <div class="cmd-box">
2274
+ <code id="lc-cmd">pga guard ai</code>
2275
+ <button class="cmd-copy" type="button" data-copy-target="lc-cmd">Copy</button>
2276
+ </div>
2277
+ <div class="desc" style="margin-top: 8px">
2278
+ When it finishes, restart Guard with
2279
+ <code>pga guard restart</code> — this panel will then show Layer C as connected.
2280
+ </div>
2281
+ </div>
2282
+
2283
+ <details style="margin-top: 14px">
2284
+ <summary class="notify-summary">How your API key is protected</summary>
2285
+ <ul class="cd-list" style="margin-top: 10px; padding-left: 18px">
2286
+ <li>
2287
+ <strong>Typed privately in the terminal.</strong> The key is read with the screen
2288
+ echo off, so it never shows on screen, never lands in your shell history, and is never
2289
+ passed as a command argument.
2290
+ </li>
2291
+ <li>
2292
+ <strong>Never entered in the browser.</strong> This dashboard deliberately has no
2293
+ field for your key — a localhost web page is a weaker place to handle a secret than
2294
+ your terminal.
2295
+ </li>
2296
+ <li>
2297
+ <strong>Encrypted at rest.</strong> Stored at
2298
+ <code>~/.panguard/llm.enc</code> with AES-256-GCM, file permissions
2299
+ <code>0600</code> (only you can read it).
2300
+ </li>
2301
+ <li>
2302
+ <strong>Locked to this machine.</strong> The encryption key is derived from this
2303
+ machine and user, so a copied file is useless anywhere else.
2304
+ </li>
2305
+ <li>
2306
+ <strong>Sent only to your provider.</strong> Your key goes only to the model provider
2307
+ you chose. It is never sent to PanGuard.
2308
+ </li>
2309
+ </ul>
2310
+ </details>
2311
+
2312
+ <div style="margin-top: 14px">
2313
+ <div style="font-size: 13px; font-weight: 600; margin-bottom: 6px">
2314
+ Turn it off / remove your key
2315
+ </div>
2316
+ <div class="desc" style="margin-bottom: 8px">
2317
+ Turns Layer C off and deletes the stored config (including any key) from this machine.
2318
+ </div>
2319
+ <div class="cmd-box">
2320
+ <code id="lc-cmd-remove">pga guard ai --remove</code>
2321
+ <button class="cmd-copy" type="button" data-copy-target="lc-cmd-remove">Copy</button>
2322
+ </div>
1352
2323
  </div>
1353
2324
  </div>
1354
2325
 
1355
2326
  <div class="fs">
1356
2327
  <h3 data-i18n="st_tc">Threat Cloud Endpoint</h3>
1357
2328
  <div class="desc" data-i18n="st_tc_desc">
1358
- URL of the Threat Cloud relay. Leave empty for the default panguard.ai endpoint, or
1359
- specify your own air-gapped relay.
2329
+ The server Guard talks to for Threat Cloud. <strong>Most people should leave this
2330
+ blank</strong> Guard uses the default (tc.panguard.ai). Change it only if you run your
2331
+ own private or air-gapped relay: when Collective Defense is on, anonymized signatures and
2332
+ the daily rule-update check go to whatever URL is set here. Your prompts, code, and keys
2333
+ are never sent regardless.
1360
2334
  </div>
1361
2335
  <div class="fr">
1362
2336
  <label data-i18n="st_endpoint">Endpoint</label>
@@ -1376,20 +2350,58 @@
1376
2350
  </div>
1377
2351
  </div>
1378
2352
 
2353
+ <div class="fs">
2354
+ <h3>Alerts</h3>
2355
+ <div class="info-box" style="border-left: 3px solid var(--ok); margin-bottom: 14px">
2356
+ <strong>You are already set up.</strong> Every threat Guard detects shows up right here
2357
+ in this dashboard — on the Overview and Threats tabs. No configuration needed.
2358
+ </div>
2359
+
2360
+ <details id="notify-details">
2361
+ <summary class="notify-summary">Send alerts somewhere else too? (optional)</summary>
2362
+ <div class="desc" style="margin-top: 10px">
2363
+ You can also push each alert to a chat or inbox you already watch. Pick one below,
2364
+ paste its line into <code>~/.panguard-guard/config.json</code> (or run
2365
+ <code>pga setup</code> for a guided walk-through), then restart Guard. Secrets are
2366
+ typed into that file, never into this dashboard. Heads-up: any channel you add sends
2367
+ alert summaries to that service (Slack's servers, your email provider, or your webhook
2368
+ URL). Alerts always stay visible here regardless.
2369
+ </div>
2370
+ <div id="st-notify-list" style="display: flex; flex-direction: column; gap: 10px"></div>
2371
+ </details>
2372
+ </div>
2373
+
2374
+ <div class="fs">
2375
+ <h3>
2376
+ Enforcement Policy
2377
+ <span style="font-weight: 400; color: var(--text-muted); font-size: 12px"
2378
+ >&mdash; what Protection mode is allowed to do</span
2379
+ >
2380
+ </h3>
2381
+ <div class="desc">
2382
+ This list is <strong>read-only here</strong> — an "armed" response only runs
2383
+ automatically when Guard Mode is set to Protection; in Learning or Report-only nothing
2384
+ automatic happens. To change what's armed, edit
2385
+ <code>enforcementPolicy</code> in <code>~/.panguard-guard/config.json</code> (or run
2386
+ <code>pga setup</code>) and restart Guard.
2387
+ </div>
2388
+ <div
2389
+ id="st-enforce-list"
2390
+ style="display: flex; flex-direction: column; gap: 6px; font-size: 13px"
2391
+ ></div>
2392
+ </div>
1379
2393
 
1380
2394
  <div style="display: flex; gap: 10px; margin-top: 8px">
1381
2395
  <button class="btn btn-primary" id="btn-save">Save</button>
1382
- <button class="btn btn-secondary" id="btn-reload">
1383
- Reload
1384
- </button>
2396
+ <button class="btn btn-secondary" id="btn-reload">Reload</button>
1385
2397
  </div>
1386
2398
 
1387
2399
  <div class="info-box" style="margin-top: 20px">
1388
2400
  <strong data-i18n="st_license">License Tier:</strong> <span id="st-tier-display">--</span
1389
2401
  ><br />
1390
2402
  <span data-i18n="st_license_desc"
1391
- >Community is free and unlimited. Pilot unlocks SARIF + Evidence Pack export and
1392
- priority Threat Cloud routing.</span
2403
+ >Community is free and unlimited including SARIF and Evidence Pack export. No features
2404
+ are locked.</span
1393
2405
  >
1394
2406
  <br /><br />
1395
2407
  <a
@@ -1398,7 +2410,7 @@
1398
2410
  rel="noopener"
1399
2411
  style="color: var(--sage)"
1400
2412
  data-i18n="st_license_link"
1401
- >View pricing &rarr;</a
2413
+ >Compare editions &rarr;</a
1402
2414
  >
1403
2415
  </div>
1404
2416
  </div>
@@ -1411,12 +2423,14 @@
1411
2423
  /* i18n strings (EN / ZH) */
1412
2424
  /* ============================================================ */
1413
2425
 
1414
-
1415
2426
  var currentSevFilter = 'all';
1416
2427
  var currentSort = { col: 'timestamp', dir: 'desc' };
1417
2428
  var allThreats = [];
1418
2429
  var allRules = [];
2430
+ var skillsData = [];
1419
2431
  var licenseTier = 'community';
2432
+ var SUPPRESS_THRESHOLD = 50;
2433
+ var showSuppressed = false;
1420
2434
 
1421
2435
  /* ============================================================ */
1422
2436
  /* Utilities */
@@ -1429,6 +2443,28 @@
1429
2443
  .replace(/"/g, '&quot;')
1430
2444
  .replace(/'/g, '&#039;');
1431
2445
  }
2446
+ /* Copy a command to the clipboard. Falls back to a manual-select prompt
2447
+ when the async clipboard API is unavailable (older browsers / non-secure
2448
+ contexts). The button briefly confirms so the user knows it worked. */
2449
+ function copyText(text, btn) {
2450
+ function ok() {
2451
+ if (!btn) return;
2452
+ var prev = btn.textContent;
2453
+ btn.textContent = 'Copied';
2454
+ btn.classList.add('copied');
2455
+ setTimeout(function () {
2456
+ btn.textContent = prev;
2457
+ btn.classList.remove('copied');
2458
+ }, 1500);
2459
+ }
2460
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2461
+ navigator.clipboard.writeText(text).then(ok, function () {
2462
+ window.prompt('Copy this command:', text);
2463
+ });
2464
+ } else {
2465
+ window.prompt('Copy this command:', text);
2466
+ }
2467
+ }
1432
2468
  function fmtUptime(ms) {
1433
2469
  var s = Math.floor(ms / 1000);
1434
2470
  if (s < 60) return s + 's';
@@ -1454,10 +2490,70 @@
1454
2490
  if (r.status === 401) {
1455
2491
  var wl = document.getElementById('wl');
1456
2492
  if (wl) wl.textContent = 'Invalid token';
2493
+ // An unauthenticated visit (bare URL, expired/cleared cookie, wrong
2494
+ // token) used to silently no-op and leave a static green "Active"
2495
+ // badge — a dead end with no path forward. Render an explicit screen
2496
+ // that tells the user exactly how to get an authenticated URL.
2497
+ showUnauthenticated();
1457
2498
  }
1458
2499
  return r;
1459
2500
  });
1460
2501
  }
2502
+ /* Explicit unauthenticated state: the cookie that authorizes /api/* is
2503
+ minted only by opening the launch URL `pga up` prints. A bare visit has
2504
+ no cookie, so every /api/* call 401s. Instead of a fake-green dead end,
2505
+ show how to get an authenticated URL and neutralize the hero badge. */
2506
+ var _unauthShown = false;
2507
+ function showUnauthenticated() {
2508
+ if (_unauthShown) return;
2509
+ _unauthShown = true;
2510
+ // Neutralize the hero so it never reads green while unauthenticated.
2511
+ var hs = document.getElementById('hero-status');
2512
+ var hd = document.getElementById('hero-dot');
2513
+ var hb = document.getElementById('hero-badge');
2514
+ if (hs) {
2515
+ hs.textContent = 'NOT AUTHENTICATED';
2516
+ hs.style.color = 'var(--warn)';
2517
+ }
2518
+ if (hd) hd.style.background = 'var(--warn)';
2519
+ if (hb) {
2520
+ hb.textContent = 'Not authenticated';
2521
+ hb.className = 'badge badge-off';
2522
+ hb.title = 'Open the launch URL from `pga up` to authenticate this dashboard.';
2523
+ }
2524
+ var ov = document.getElementById('unauth-overlay');
2525
+ if (ov) {
2526
+ ov.style.display = 'flex';
2527
+ return;
2528
+ }
2529
+ ov = document.createElement('div');
2530
+ ov.id = 'unauth-overlay';
2531
+ ov.style.cssText =
2532
+ 'position:fixed;inset:0;z-index:9999;display:flex;align-items:center;' +
2533
+ 'justify-content:center;background:rgba(11,13,16,0.92);padding:24px';
2534
+ ov.innerHTML =
2535
+ '<div style="max-width:460px;background:var(--surface-2,#1b1f24);' +
2536
+ 'border:1px solid var(--border,#2a2f36);border-radius:12px;padding:28px;' +
2537
+ 'text-align:left;color:var(--text,#e6e8eb)">' +
2538
+ '<div style="font-size:16px;font-weight:600;margin-bottom:10px">' +
2539
+ 'This dashboard is not authenticated</div>' +
2540
+ '<div style="font-size:13px;color:var(--text-muted,#9aa1a9);line-height:1.6;margin-bottom:16px">' +
2541
+ 'For your security, the dashboard only accepts requests carrying a ' +
2542
+ 'one-time launch token. Opening this URL directly does not include it, ' +
2543
+ 'so live status and controls are unavailable.</div>' +
2544
+ '<div style="font-size:13px;color:var(--text,#e6e8eb);margin-bottom:8px">' +
2545
+ 'To get an authenticated URL, run:</div>' +
2546
+ '<div style="background:var(--surface-3,#0f1216);border-radius:8px;padding:12px 14px;' +
2547
+ 'font-family:ui-monospace,monospace;font-size:13px;color:var(--emerald,#34d399)">' +
2548
+ 'pga up</div>' +
2549
+ '<div style="font-size:12px;color:var(--text-muted,#9aa1a9);line-height:1.6;margin-top:14px">' +
2550
+ 'It prints a link like ' +
2551
+ '<code>http://127.0.0.1:&lt;port&gt;/?token=&hellip;</code> — open that ' +
2552
+ 'link to authenticate. If Guard is already running, re-run ' +
2553
+ '<code>pga up</code> to reprint the authenticated link.</div>' +
2554
+ '</div>';
2555
+ document.body.appendChild(ov);
2556
+ }
1461
2557
 
1462
2558
  /* ============================================================ */
1463
2559
  /* i18n + language toggle */
@@ -1496,6 +2592,12 @@
1496
2592
  if (tab === 'settings') {
1497
2593
  loadSettings();
1498
2594
  }
2595
+ if (tab === 'coverage') {
2596
+ loadCoverage();
2597
+ }
2598
+ if (tab === 'runtime') {
2599
+ loadRuntime();
2600
+ }
1499
2601
  }
1500
2602
  document.querySelectorAll('.ni').forEach(function (n) {
1501
2603
  n.addEventListener('click', function () {
@@ -1504,20 +2606,15 @@
1504
2606
  });
1505
2607
 
1506
2608
  /* ============================================================ */
1507
- /* License tier gating */
2609
+ /* License tier label (no feature gating — everything is free) */
1508
2610
  /* ============================================================ */
1509
2611
  function applyTierGates(status) {
1510
2612
  var tier = (status && status.license && status.license.tier) || 'community';
1511
2613
  licenseTier = tier;
1512
- var isPaid = tier === 'pilot' || tier === 'enterprise' || tier === 'pro';
1513
- document.querySelectorAll('[data-tier-gate="pilot"]').forEach(function (el) {
1514
- el.style.display = isPaid ? 'inline-flex' : 'none';
1515
- });
1516
- document.querySelectorAll('[data-tier-upsell="pilot"]').forEach(function (el) {
1517
- el.style.display = isPaid ? 'none' : 'inline-flex';
1518
- });
1519
2614
  var badge = document.getElementById('lic-badge');
1520
2615
  if (badge) badge.textContent = tier.toUpperCase();
2616
+ var brandTier = document.getElementById('brand-tier');
2617
+ if (brandTier) brandTier.textContent = tier.charAt(0).toUpperCase() + tier.slice(1);
1521
2618
  var dispTier = document.getElementById('st-tier-display');
1522
2619
  if (dispTier) dispTier.textContent = tier;
1523
2620
  }
@@ -1525,15 +2622,143 @@
1525
2622
  /* ============================================================ */
1526
2623
  /* WebSocket */
1527
2624
  /* ============================================================ */
2625
+ /* ----------------------------------------------------------------
2626
+ Connection state — ONE source of truth for "is Guard up?".
2627
+ Drives pbar + hero + posture + footer together so they never
2628
+ disagree. 'down' (Guard unreachable) is distinct from the 401
2629
+ unauthenticated screen and surfaces the start-it guidance. The
2630
+ old code left a stale green "CONNECTING…" forever when Guard was
2631
+ simply not running; this collapses that into one honest answer.
2632
+ ---------------------------------------------------------------- */
2633
+ var _connState = '';
2634
+ var _connectTimer = null;
2635
+ var _downGrace = null;
2636
+ function markConnLive() {
2637
+ _connState = 'live';
2638
+ if (_connectTimer) {
2639
+ clearTimeout(_connectTimer);
2640
+ _connectTimer = null;
2641
+ }
2642
+ if (_downGrace) {
2643
+ clearTimeout(_downGrace);
2644
+ _downGrace = null;
2645
+ }
2646
+ var b = document.getElementById('conn-banner');
2647
+ if (b) b.style.display = 'none';
2648
+ var wl = document.getElementById('wl');
2649
+ var wd = document.getElementById('wd');
2650
+ if (wl && wl.textContent !== 'Invalid token') wl.textContent = 'Connected';
2651
+ if (wd) wd.classList.add('on');
2652
+ }
2653
+ // When Guard is unreachable we cannot claim any layer is "Active" — the
2654
+ // HTML defaults to Active and renderLayers() only runs on live status, so
2655
+ // neutralize the layer rows to avoid a residual false-green.
2656
+ function setLayersUnknown() {
2657
+ ['la', 'lb', 'lc'].forEach(function (k) {
2658
+ var bar = document.getElementById(k + '-bar');
2659
+ var st = document.getElementById(k + '-st');
2660
+ if (bar) bar.className = 'layer-fill off';
2661
+ if (st) {
2662
+ st.textContent = '—';
2663
+ st.className = 'layer-status off';
2664
+ }
2665
+ });
2666
+ var ld = document.getElementById('layers-dot');
2667
+ if (ld) ld.style.background = 'var(--text-muted)';
2668
+ }
2669
+ function setConnState(state) {
2670
+ if (state === 'live') {
2671
+ markConnLive();
2672
+ return;
2673
+ }
2674
+ if (state === _connState) return;
2675
+ _connState = state;
2676
+ setLayersUnknown();
2677
+ var dot = document.getElementById('pb-dot');
2678
+ var txt = document.getElementById('pb-txt');
2679
+ var det = document.getElementById('pb-detail');
2680
+ var hs = document.getElementById('hero-status');
2681
+ var hd = document.getElementById('hero-dot');
2682
+ var hb = document.getElementById('hero-badge');
2683
+ var hdsc = document.getElementById('hero-desc');
2684
+ var sc = document.getElementById('posture-score');
2685
+ var pl = document.getElementById('posture-label');
2686
+ var pbar = document.getElementById('posture-bar');
2687
+ var banner = document.getElementById('conn-banner');
2688
+ var wl = document.getElementById('wl');
2689
+ var wd = document.getElementById('wd');
2690
+ if (state === 'connecting') {
2691
+ if (dot) dot.className = 'pb-dot warn';
2692
+ if (txt) txt.textContent = 'Connecting…';
2693
+ if (hs) {
2694
+ hs.textContent = 'Connecting…';
2695
+ hs.style.color = 'var(--text-muted)';
2696
+ }
2697
+ if (hd) hd.style.background = 'var(--text-muted)';
2698
+ if (hb) {
2699
+ hb.textContent = 'Connecting…';
2700
+ hb.className = 'badge badge-off';
2701
+ }
2702
+ if (hdsc) hdsc.textContent = 'Checking Guard status on this machine…';
2703
+ if (banner) banner.style.display = 'none';
2704
+ if (wl && wl.textContent !== 'Invalid token') wl.textContent = 'Connecting…';
2705
+ if (wd) wd.classList.remove('on');
2706
+ } else if (state === 'down') {
2707
+ if (dot) dot.className = 'pb-dot bad';
2708
+ if (txt) txt.textContent = 'GUARD NOT RUNNING';
2709
+ if (det) det.textContent = '| start Guard to resume protection';
2710
+ if (hs) {
2711
+ hs.textContent = 'GUARD NOT RUNNING';
2712
+ hs.style.color = 'var(--danger)';
2713
+ }
2714
+ if (hd) hd.style.background = 'var(--danger)';
2715
+ if (hb) {
2716
+ hb.textContent = 'Not running';
2717
+ hb.className = 'badge badge-off';
2718
+ }
2719
+ if (hdsc)
2720
+ hdsc.textContent =
2721
+ 'PanGuard Guard is not running on this machine, so your agents are not ' +
2722
+ 'being watched right now.';
2723
+ if (sc) {
2724
+ sc.textContent = '--';
2725
+ sc.style.color = 'var(--danger)';
2726
+ }
2727
+ if (pl) pl.textContent = 'Guard isn’t running';
2728
+ if (pbar) pbar.style.width = '0';
2729
+ if (banner) banner.style.display = 'flex';
2730
+ if (wl && wl.textContent !== 'Invalid token') wl.textContent = 'Not running';
2731
+ if (wd) wd.classList.remove('on');
2732
+ }
2733
+ }
2734
+
1528
2735
  function connectWS() {
1529
2736
  var ws = new WebSocket('ws://' + location.host + '/ws');
1530
2737
  ws.onopen = function () {
1531
- document.getElementById('wd').classList.add('on');
1532
- document.getElementById('wl').textContent = 'Connected';
2738
+ if (_downGrace) {
2739
+ clearTimeout(_downGrace);
2740
+ _downGrace = null;
2741
+ }
2742
+ var wd = document.getElementById('wd');
2743
+ var wl = document.getElementById('wl');
2744
+ if (wd) wd.classList.add('on');
2745
+ if (wl && wl.textContent !== 'Invalid token' && _connState !== 'down')
2746
+ wl.textContent = 'Connected';
1533
2747
  };
1534
2748
  ws.onclose = function () {
1535
- document.getElementById('wd').classList.remove('on');
1536
- document.getElementById('wl').textContent = 'Disconnected';
2749
+ var wd = document.getElementById('wd');
2750
+ if (wd) wd.classList.remove('on');
2751
+ // If we were live, give a short grace for an immediate reconnect before
2752
+ // declaring Guard down — avoids flashing the banner on transient blips.
2753
+ if (_connState === 'live') {
2754
+ var wl = document.getElementById('wl');
2755
+ if (wl && wl.textContent !== 'Invalid token') wl.textContent = 'Reconnecting…';
2756
+ if (!_downGrace) {
2757
+ _downGrace = setTimeout(function () {
2758
+ if (_connState !== 'live') setConnState('down');
2759
+ }, 4000);
2760
+ }
2761
+ }
1537
2762
  setTimeout(connectWS, 3000);
1538
2763
  };
1539
2764
  ws.onmessage = function (e) {
@@ -1563,6 +2788,13 @@
1563
2788
  }
1564
2789
  function updateStatus(s) {
1565
2790
  if (!s) return;
2791
+ markConnLive();
2792
+ // First-run welcome: only ever after a confirmed live+authenticated status,
2793
+ // so it can never appear over the "Guard not running" banner or 401 overlay.
2794
+ if (!_welcomeChecked) {
2795
+ _welcomeChecked = true;
2796
+ maybeShowWelcome();
2797
+ }
1566
2798
  setKPI('v-rules', (s.atrRuleCount || 0).toLocaleString());
1567
2799
  var th = s.threatsDetected || 0;
1568
2800
  setKPI('v-threats', th);
@@ -1572,34 +2804,327 @@
1572
2804
  setKPI('v-events', (s.eventsProcessed || 0).toLocaleString());
1573
2805
  setKPI('v-mode', s.mode || '--');
1574
2806
 
2807
+ // Honest posture label: PROTECTED only when the daemon will actually ACT
2808
+ // (protection mode + an armed response). 'protection' mode with no armed
2809
+ // OS response is MONITORING (detect + notify), not green PROTECTED.
2810
+ var p = postureView(s);
1575
2811
  var hs = document.getElementById('hero-status');
1576
2812
  var hd = document.getElementById('hero-dot');
1577
2813
  var hb = document.getElementById('hero-badge');
1578
2814
  if (hs) {
1579
- hs.textContent = s.mode === 'protection' ? 'PROTECTED' : 'LEARNING';
1580
- hs.style.color = s.mode === 'protection' ? 'var(--ok)' : 'var(--warn)';
2815
+ hs.textContent = p.label;
2816
+ hs.style.color = p.color;
2817
+ hs.title = p.detail;
1581
2818
  }
1582
- if (hd) hd.style.background = s.mode === 'protection' ? 'var(--ok)' : 'var(--warn)';
2819
+ if (hd) hd.style.background = p.color;
1583
2820
  if (hb) {
1584
- hb.textContent = s.mode === 'protection' ? 'PROTECTED' : 'LEARNING';
1585
- hb.className =
1586
- 'badge ' + (s.mode === 'protection' ? 'badge-protected' : 'badge-learning');
2821
+ hb.textContent = p.label;
2822
+ hb.className = 'badge ' + p.badge;
2823
+ hb.title = p.detail;
1587
2824
  }
2825
+ var hdsc = document.getElementById('hero-desc');
2826
+ if (hdsc && p.sub) hdsc.textContent = p.sub;
1588
2827
  updatePBar(s);
2828
+ renderLayers(s.layers);
2829
+ _lastStatus = s;
2830
+ renderPosture(s);
1589
2831
  applyTierGates(s);
1590
2832
  }
2833
+ /* Posture score — one honest number from real local state, with each
2834
+ deduction offering a one-click jump to fix it. Formula (founder-set
2835
+ default): start 100; −min(threats*8,40) unresolved threats; −30 if the
2836
+ deterministic engine isn't actually running rules; −min(unprotected*5,20)
2837
+ detected-but-unconnected agent platforms. The optional semantic layer
2838
+ being off is NOT penalized — it is opt-in by design. */
2839
+ var _lastStatus = null;
2840
+ var _agentUnprotected = 0;
2841
+ function renderPosture(s) {
2842
+ if (!s) return;
2843
+ var score = 100;
2844
+ var ded = [];
2845
+ var threats = s.threatsDetected || 0;
2846
+ if (threats > 0) {
2847
+ var pt = Math.min(threats * 8, 40);
2848
+ score -= pt;
2849
+ ded.push({
2850
+ label: threats + ' unresolved threat' + (threats > 1 ? 's' : ''),
2851
+ pts: pt,
2852
+ goto: 'threats',
2853
+ btn: 'Review',
2854
+ });
2855
+ }
2856
+ var la = s.layers && s.layers.a;
2857
+ if (la && la.state !== 'active') {
2858
+ score -= 30;
2859
+ ded.push({
2860
+ label: 'Detection engine: ' + (la.detail || 'not running'),
2861
+ pts: 30,
2862
+ goto: 'rules',
2863
+ btn: 'Inspect',
2864
+ });
2865
+ }
2866
+ if (_agentUnprotected > 0) {
2867
+ var pa = Math.min(_agentUnprotected * 5, 20);
2868
+ score -= pa;
2869
+ ded.push({
2870
+ label:
2871
+ _agentUnprotected +
2872
+ ' agent platform' +
2873
+ (_agentUnprotected > 1 ? 's' : '') +
2874
+ ' unprotected',
2875
+ pts: pa,
2876
+ goto: 'coverage',
2877
+ btn: 'Connect',
2878
+ });
2879
+ }
2880
+ // Honesty: 'protection' mode that arms NO automatic response is detect-and-
2881
+ // notify, not prevention. Don't let the score read 'Strong' while the daemon
2882
+ // would take no action. (report-only / learning make no such claim → no penalty.)
2883
+ if (s.enforcement && s.enforcement.mode === 'protection' && !s.enforcement.osActionsArmed) {
2884
+ score -= 10;
2885
+ ded.push({
2886
+ label: 'Protection mode on, but no automatic response is armed',
2887
+ pts: 10,
2888
+ goto: 'settings',
2889
+ btn: 'Configure',
2890
+ });
2891
+ }
2892
+ score = Math.max(0, score);
2893
+ var color = score >= 85 ? 'var(--emerald)' : score >= 60 ? 'var(--warn)' : 'var(--danger)';
2894
+ var label =
2895
+ score >= 85
2896
+ ? 'Strong — nothing needs your attention'
2897
+ : score >= 60
2898
+ ? 'Fair — a few things to address'
2899
+ : 'At risk — action needed';
2900
+ var sc = document.getElementById('posture-score');
2901
+ if (sc) {
2902
+ sc.textContent = score;
2903
+ sc.style.color = color;
2904
+ sc.title =
2905
+ 'Starts at 100. Points come off for unresolved threats, a detection engine that ' +
2906
+ 'isn’t running, and agent platforms you haven’t connected. The optional semantic ' +
2907
+ 'layer (Layer C) being off never lowers your score — it’s opt-in by design.';
2908
+ }
2909
+ var bar = document.getElementById('posture-bar');
2910
+ if (bar) {
2911
+ bar.style.width = score + '%';
2912
+ bar.style.background = color;
2913
+ }
2914
+ var lb = document.getElementById('posture-label');
2915
+ if (lb) lb.textContent = label;
2916
+ var dd = document.getElementById('posture-deductions');
2917
+ if (dd) {
2918
+ if (!ded.length) {
2919
+ dd.innerHTML =
2920
+ '<div style="font-size:12px;color:var(--text-muted)">Nothing needs your ' +
2921
+ 'attention right now.</div>';
2922
+ } else {
2923
+ dd.innerHTML = '';
2924
+ ded.forEach(function (d) {
2925
+ var row = document.createElement('div');
2926
+ row.style.cssText =
2927
+ 'display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px';
2928
+ row.innerHTML =
2929
+ '<span>' +
2930
+ esc(d.label) +
2931
+ ' <span style="color:var(--danger)">&minus;' +
2932
+ d.pts +
2933
+ '</span></span>' +
2934
+ '<button type="button" class="btn btn-secondary btn-sm" data-goto="' +
2935
+ d.goto +
2936
+ '">' +
2937
+ esc(d.btn) +
2938
+ '</button>';
2939
+ dd.appendChild(row);
2940
+ });
2941
+ }
2942
+ }
2943
+ }
2944
+ /* Pull the unprotected-platform count for the posture score (Coverage data). */
2945
+ function refreshPostureAgents() {
2946
+ af('/api/agents')
2947
+ .then(function (r) {
2948
+ return r.json();
2949
+ })
2950
+ .then(function (d) {
2951
+ _agentUnprotected = Math.max(0, (d.detected || 0) - (d.configured || 0));
2952
+ if (_lastStatus) renderPosture(_lastStatus);
2953
+ })
2954
+ .catch(function () {
2955
+ /* leave at 0 */
2956
+ });
2957
+ }
2958
+ /* Real per-layer health from the server (never hard-coded green). The
2959
+ server reports state per layer; Layer A goes red 'Degraded' if zero
2960
+ rules are loaded, and Layer C shows the BYO-LLM guidance inline. */
2961
+ function renderLayers(L) {
2962
+ if (!L) return;
2963
+ // Live status arrived: restore the section dot (cleared to neutral while
2964
+ // Guard was unreachable) so it reflects real per-layer health again.
2965
+ var ld = document.getElementById('layers-dot');
2966
+ if (ld) ld.style.background = '';
2967
+ var map = { a: 'la', b: 'lb', c: 'lc' };
2968
+ Object.keys(map).forEach(function (k) {
2969
+ var ly = L[k];
2970
+ if (!ly) return;
2971
+ var active = ly.state === 'active';
2972
+ var color =
2973
+ ly.state === 'active'
2974
+ ? 'var(--ok)'
2975
+ : ly.state === 'degraded'
2976
+ ? 'var(--danger)'
2977
+ : ly.state === 'idle'
2978
+ ? 'var(--warn)'
2979
+ : 'var(--text-muted)';
2980
+ var label = active
2981
+ ? 'Active'
2982
+ : ly.state === 'degraded'
2983
+ ? 'Degraded'
2984
+ : ly.state === 'idle'
2985
+ ? 'Idle'
2986
+ : ly.optional
2987
+ ? 'Off · bring your own'
2988
+ : 'Off';
2989
+ var st = document.getElementById(map[k] + '-st');
2990
+ var bar = document.getElementById(map[k] + '-bar');
2991
+ if (st) {
2992
+ st.textContent = label;
2993
+ st.style.color = color;
2994
+ if (ly.detail) st.title = ly.detail;
2995
+ }
2996
+ if (bar) bar.className = 'layer-fill' + (active ? ' on' : '');
2997
+ });
2998
+ // Layer C call-to-action: a real two-state affordance (not a server-filled
2999
+ // hint). Not connected -> why + "Set up Layer C"; connected -> calm note.
3000
+ var cOn = L.c && L.c.state === 'active';
3001
+ var ctaText = document.getElementById('lc-cta-text');
3002
+ var ctaBtn = document.getElementById('lc-cta-btn');
3003
+ if (ctaText && ctaBtn) {
3004
+ if (cOn) {
3005
+ ctaText.textContent =
3006
+ 'Layer C is connected and adding an advisory second opinion on flagged events. ' +
3007
+ 'It never blocks on its own.';
3008
+ ctaBtn.textContent = 'Manage Layer C';
3009
+ } else {
3010
+ ctaText.textContent =
3011
+ 'Optional: connect your own LLM for a second opinion on flagged events — catches ' +
3012
+ 'novel attacks A and B miss. Advisory only: it flags for review, never auto-blocks. ' +
3013
+ 'Free with a local model, or a few cents a month with a cloud key.';
3014
+ ctaBtn.textContent = 'Set up Layer C';
3015
+ }
3016
+ }
3017
+ renderLayerCSettings(L);
3018
+ }
3019
+ /* Reflect Layer C connected/off state in the Settings section header. Defaults
3020
+ to Off in markup so it never reads green before real status loads. */
3021
+ function renderLayerCSettings(L) {
3022
+ var on = L && L.c && L.c.state === 'active';
3023
+ var dot = document.getElementById('lc-set-dot');
3024
+ var st = document.getElementById('lc-set-state');
3025
+ if (dot) dot.className = 'dot ' + (on ? 'dot-ok' : 'dot-off');
3026
+ if (st) {
3027
+ st.textContent = on ? 'Connected · advisory' : 'Off · optional';
3028
+ st.style.color = on ? 'var(--ok)' : 'var(--text-muted)';
3029
+ }
3030
+ }
3031
+
3032
+ /* ----------------------------------------------------------------
3033
+ First-run welcome guide. Shown once on the first LIVE authenticated
3034
+ status (called from updateStatus, after markConnLive), gated by a
3035
+ localStorage flag so it never nags and never paints over the down/
3036
+ unauth states. "Got it"/X persist; "Dismiss for now" returns next
3037
+ launch; the sidebar link reopens it.
3038
+ ---------------------------------------------------------------- */
3039
+ var _welcomeChecked = false;
3040
+ function maybeShowWelcome() {
3041
+ try {
3042
+ if (localStorage.getItem('pg.welcome.dismissed') === '1') return;
3043
+ } catch (e) {
3044
+ /* private mode: fall through and show once for this page load */
3045
+ }
3046
+ var w = document.getElementById('welcome-guide');
3047
+ if (w) w.style.display = '';
3048
+ }
3049
+ function hideWelcome(persist) {
3050
+ var w = document.getElementById('welcome-guide');
3051
+ if (w) w.style.display = 'none';
3052
+ if (persist) {
3053
+ try {
3054
+ localStorage.setItem('pg.welcome.dismissed', '1');
3055
+ } catch (e) {
3056
+ /* ignore */
3057
+ }
3058
+ }
3059
+ }
3060
+ function reopenWelcome() {
3061
+ try {
3062
+ localStorage.removeItem('pg.welcome.dismissed');
3063
+ } catch (e) {
3064
+ /* ignore */
3065
+ }
3066
+ nav('overview');
3067
+ var w = document.getElementById('welcome-guide');
3068
+ if (w) w.style.display = '';
3069
+ }
3070
+ /* Map the server's honest enforcement posture to a label/color. PROTECTED
3071
+ (emerald) ONLY when the daemon will actually act; 'protection' mode with
3072
+ no armed response → MONITORING (amber), not a green over-claim. */
3073
+ function postureView(s) {
3074
+ var enf = s.enforcement || {
3075
+ posture: s.mode === 'protection' ? 'protected' : s.mode || 'learning',
3076
+ armedActions: [],
3077
+ };
3078
+ var MAP = {
3079
+ protected: {
3080
+ label: 'PROTECTED',
3081
+ color: 'var(--emerald)',
3082
+ badge: 'badge-protected',
3083
+ detail: 'Detecting and auto-responding (' + (enf.armedActions || []).join(', ') + ')',
3084
+ sub: 'Your AI agents are protected. Threats are detected and blocked automatically.',
3085
+ },
3086
+ monitoring: {
3087
+ label: 'MONITORING',
3088
+ color: 'var(--warn)',
3089
+ badge: 'badge-learning',
3090
+ detail:
3091
+ 'Detecting + notifying. No automatic OS response is armed — arm it under ' +
3092
+ 'enforcementPolicy in config to auto-act.',
3093
+ sub: 'Watching every action and alerting you. No automatic blocking is armed yet.',
3094
+ },
3095
+ 'report-only': {
3096
+ label: 'REPORT-ONLY',
3097
+ color: 'var(--sage)',
3098
+ badge: 'badge-learning',
3099
+ detail: 'Recording detections only; takes no action by design.',
3100
+ sub: 'Recording detections for your review. Guard takes no action by design.',
3101
+ },
3102
+ learning: {
3103
+ label: 'LEARNING',
3104
+ color: 'var(--warn)',
3105
+ badge: 'badge-learning',
3106
+ detail: 'Building a baseline of normal activity; logging only.',
3107
+ sub: 'Learning what normal looks like on your machine. Logging only for now.',
3108
+ },
3109
+ off: {
3110
+ label: 'OFFLINE',
3111
+ color: 'var(--danger)',
3112
+ badge: 'badge-learning',
3113
+ detail: 'Guard is not running.',
3114
+ sub: 'Guard is not running, so your agents are not being watched.',
3115
+ },
3116
+ };
3117
+ return MAP[enf.posture] || MAP.learning;
3118
+ }
1591
3119
  function updatePBar(s) {
1592
3120
  var dot = document.getElementById('pb-dot');
1593
3121
  var txt = document.getElementById('pb-txt');
1594
3122
  var det = document.getElementById('pb-detail');
1595
3123
  if (!dot || !txt || !det) return;
1596
- var running = s.mode === 'protection' || s.mode === 'learning';
3124
+ var running = s.mode === 'protection' || s.mode === 'learning' || s.mode === 'report-only';
3125
+ var p = postureView(s);
1597
3126
  dot.className = 'pb-dot ' + (running ? 'ok' : 'bad');
1598
- txt.textContent = running
1599
- ? s.mode === 'protection'
1600
- ? 'PROTECTED'
1601
- : 'LEARNING'
1602
- : 'OFFLINE';
3127
+ txt.textContent = running ? p.label : 'OFFLINE';
1603
3128
  var rules = s.atrRuleCount || 0;
1604
3129
  det.textContent =
1605
3130
  '| ' +
@@ -1614,21 +3139,37 @@
1614
3139
  if (emp) emp.style.display = 'none';
1615
3140
  var log = document.getElementById('ev-log');
1616
3141
  if (log) log.style.display = '';
1617
- var d = document.createElement('div');
1618
- var sev = (m.data && (m.data.severity || m.data.conclusion)) || 'benign';
1619
- d.className = 'ei ' + sev;
3142
+ var data = (m && m.data) || {};
3143
+ var sev = String(data.severity || data.conclusion || 'benign').toLowerCase();
1620
3144
  var t = m.timestamp ? m.timestamp.split('T')[1].split('.')[0] : '--:--:--';
1621
- d.innerHTML =
1622
- '<span class="ei-t">' +
1623
- t +
1624
- '</span>' +
1625
- '<span class="ei-y">' +
1626
- esc(m.type || '') +
3145
+ // Plain-English summary instead of a raw JSON dump — a solo dev scans
3146
+ // these. Prefer a human label; fall back to rule id / event type.
3147
+ var title =
3148
+ data.ruleName ||
3149
+ data.message ||
3150
+ data.ruleId ||
3151
+ (m.type === 'new_verdict' ? 'Action reviewed' : 'Event detected');
3152
+ var bits = [];
3153
+ if (data.action) bits.push(String(data.action));
3154
+ if (data.tool) bits.push(String(data.tool));
3155
+ if (data.matchedPattern) bits.push('matched ' + String(data.matchedPattern).slice(0, 56));
3156
+ var card = document.createElement('div');
3157
+ card.className = 'ev-card ' + sev;
3158
+ card.innerHTML =
3159
+ '<span class="ev-sev"></span>' +
3160
+ '<div class="ev-body">' +
3161
+ '<div class="ev-title">' +
3162
+ esc(String(title).slice(0, 140)) +
3163
+ '</div>' +
3164
+ (bits.length ? '<div class="ev-meta">' + esc(bits.join(' · ')) + '</div>' : '') +
3165
+ '</div>' +
3166
+ '<span class="ev-sevtag">' +
3167
+ esc(sev) +
1627
3168
  '</span>' +
1628
- '<span class="ei-d">' +
1629
- esc(JSON.stringify(m.data || {}).slice(0, 200)) +
3169
+ '<span class="ev-time">' +
3170
+ t +
1630
3171
  '</span>';
1631
- log.prepend(d);
3172
+ log.prepend(card);
1632
3173
  while (log.children.length > 20) log.removeChild(log.lastChild);
1633
3174
  }
1634
3175
 
@@ -1642,8 +3183,12 @@
1642
3183
  })
1643
3184
  .then(updateStatus)
1644
3185
  .catch(function () {
1645
- /* ignore */
3186
+ // Fetch failed (not a 401 — that path shows the unauthenticated
3187
+ // screen). On localhost this means Guard isn't reachable: surface
3188
+ // the one honest "not running" state instead of a silent no-op.
3189
+ if (!_unauthShown) setConnState('down');
1646
3190
  });
3191
+ refreshPostureAgents();
1647
3192
  }
1648
3193
 
1649
3194
  function loadThreats() {
@@ -1653,15 +3198,32 @@
1653
3198
  })
1654
3199
  .then(function (verdicts) {
1655
3200
  allThreats = (verdicts || []).map(function (v, i) {
3201
+ // Real ATR rule id lives in a rule_match evidence's data.ruleId —
3202
+ // evidence[0].source is the evidence TYPE (e.g. "rule_match"), not an id.
3203
+ var ruleMatch = (v.evidence || []).filter(function (e) {
3204
+ return e && e.source === 'rule_match' && e.data && e.data.ruleId;
3205
+ })[0];
3206
+ var ruleId = ruleMatch
3207
+ ? ruleMatch.data.ruleId
3208
+ : v.mitreTechnique || v.evidence?.[0]?.source || 'heuristic-' + i;
3209
+ // Enforcement confidence (deterministic-only) drives the displayed
3210
+ // confidence; falls back to overall confidence.
3211
+ var conf =
3212
+ typeof v.enforcementConfidence === 'number'
3213
+ ? v.enforcementConfidence
3214
+ : typeof v.confidence === 'number'
3215
+ ? v.confidence
3216
+ : 0;
1656
3217
  return {
1657
3218
  timestamp: v.timestamp || new Date().toISOString(),
1658
- ruleId: (v.evidence && v.evidence[0] && v.evidence[0].source) || 'rule-' + i,
3219
+ ruleId: ruleId,
1659
3220
  severity:
1660
3221
  v.conclusion === 'malicious'
1661
3222
  ? 'critical'
1662
3223
  : v.conclusion === 'suspicious'
1663
3224
  ? 'medium'
1664
3225
  : 'low',
3226
+ confidence: Math.round(conf),
1665
3227
  pattern: (v.reasoning || '').slice(0, 160),
1666
3228
  action: v.recommendedAction || 'log_only',
1667
3229
  };
@@ -1682,10 +3244,44 @@
1682
3244
  function renderThreats() {
1683
3245
  var tb = document.getElementById('th-tb');
1684
3246
  var emp = document.getElementById('th-empty');
3247
+ var sum = document.getElementById('th-summary');
1685
3248
  tb.innerHTML = '';
1686
- var list = allThreats.filter(function (t) {
3249
+ var bySev = allThreats.filter(function (t) {
1687
3250
  return currentSevFilter === 'all' || t.severity === currentSevFilter;
1688
3251
  });
3252
+ // Anti alert-fatigue: low-confidence detections are suppressed from the
3253
+ // default view (the #1 reason solo devs mute a security tool). The count
3254
+ // stays visible and one click reveals them.
3255
+ var high = bySev.filter(function (t) {
3256
+ return t.confidence >= SUPPRESS_THRESHOLD;
3257
+ });
3258
+ var low = bySev.filter(function (t) {
3259
+ return t.confidence < SUPPRESS_THRESHOLD;
3260
+ });
3261
+ if (sum) {
3262
+ if (low.length > 0) {
3263
+ sum.style.display = '';
3264
+ sum.innerHTML =
3265
+ 'Showing ' +
3266
+ high.length +
3267
+ ' high-confidence · ' +
3268
+ '<span id="th-supp-toggle" style="text-decoration:underline;cursor:pointer">' +
3269
+ low.length +
3270
+ ' low-confidence ' +
3271
+ (showSuppressed ? 'hidden (click to hide)' : 'hidden — show') +
3272
+ '</span>' +
3273
+ ' <span style="color:var(--text-muted)">(still recorded — hidden by default to cut noise)</span>';
3274
+ var tg = document.getElementById('th-supp-toggle');
3275
+ if (tg)
3276
+ tg.addEventListener('click', function () {
3277
+ showSuppressed = !showSuppressed;
3278
+ renderThreats();
3279
+ });
3280
+ } else {
3281
+ sum.style.display = 'none';
3282
+ }
3283
+ }
3284
+ var list = (showSuppressed ? bySev : high).slice();
1689
3285
  list.sort(function (a, b) {
1690
3286
  var av = a[currentSort.col],
1691
3287
  bv = b[currentSort.col];
@@ -1699,6 +3295,7 @@
1699
3295
  if (emp) emp.style.display = 'none';
1700
3296
  list.forEach(function (t) {
1701
3297
  var tr = document.createElement('tr');
3298
+ if (t.confidence < SUPPRESS_THRESHOLD) tr.style.opacity = '0.6';
1702
3299
  var ts = t.timestamp.split('T')[1]
1703
3300
  ? t.timestamp.split('T')[1].split('.')[0]
1704
3301
  : t.timestamp;
@@ -1715,6 +3312,9 @@
1715
3312
  esc(t.severity) +
1716
3313
  '</span></td>' +
1717
3314
  '<td class="mono">' +
3315
+ t.confidence +
3316
+ '%</td>' +
3317
+ '<td class="mono">' +
1718
3318
  esc(t.pattern) +
1719
3319
  '</td>' +
1720
3320
  '<td>' +
@@ -1760,7 +3360,7 @@
1760
3360
  a.click();
1761
3361
  document.body.removeChild(a);
1762
3362
  URL.revokeObjectURL(url);
1763
- toast('SARIF exported');
3363
+ toast('SARIF file downloaded to your computer — nothing was uploaded.');
1764
3364
  })
1765
3365
  .catch(function () {
1766
3366
  toast('Network error');
@@ -1785,14 +3385,45 @@
1785
3385
  a.click();
1786
3386
  document.body.removeChild(a);
1787
3387
  URL.revokeObjectURL(url);
1788
- toast('Evidence pack generated');
3388
+ toast('Evidence pack downloaded to your computer — nothing was uploaded.');
1789
3389
  })
1790
3390
  .catch(function () {
1791
3391
  toast('Network error');
1792
3392
  });
1793
3393
  }
1794
3394
 
3395
+ /* Surface the daily notify-only rule-update check (rule-sync writes it to
3396
+ rule-update.json). The server computed this 'newer rules published'
3397
+ signal but nothing read it. Notify-only — never auto-applies. */
3398
+ function loadRuleUpdate() {
3399
+ var b = document.getElementById('rule-update-banner');
3400
+ if (!b) return;
3401
+ af('/api/rule-update')
3402
+ .then(function (r) {
3403
+ return r.json();
3404
+ })
3405
+ .then(function (d) {
3406
+ if (d && d.updateAvailable && d.latestVersion) {
3407
+ b.innerHTML =
3408
+ '<strong>New detection rules available</strong> &mdash; agent-threat-rules ' +
3409
+ esc(d.currentVersion || '?') +
3410
+ ' &rarr; ' +
3411
+ esc(d.latestVersion) +
3412
+ '. Run <code>pga upgrade</code> in your terminal when you\'re ready — your current ' +
3413
+ 'rules keep protecting you until you do, and nothing changes automatically. Rules ' +
3414
+ 'are verified by npm and never auto-applied.';
3415
+ b.style.display = '';
3416
+ } else {
3417
+ b.style.display = 'none';
3418
+ }
3419
+ })
3420
+ .catch(function () {
3421
+ b.style.display = 'none';
3422
+ });
3423
+ }
3424
+
1795
3425
  function loadRules() {
3426
+ loadRuleUpdate();
1796
3427
  af('/api/rules')
1797
3428
  .then(function (r) {
1798
3429
  return r.json();
@@ -1886,7 +3517,110 @@
1886
3517
  renderRules(f);
1887
3518
  }
1888
3519
 
3520
+ /* Coverage — surfaces /api/agents (which platforms are detected + whether
3521
+ each is wired to the proxy). This data drove zero UI before; a detected
3522
+ but un-connected platform is running unprotected. */
3523
+ function loadCoverage() {
3524
+ var ld = document.getElementById('cov-loading');
3525
+ var tbl = document.getElementById('cov-table');
3526
+ var tb = document.getElementById('cov-tb');
3527
+ if (!tb) return;
3528
+ af('/api/agents')
3529
+ .then(function (r) {
3530
+ return r.json();
3531
+ })
3532
+ .then(function (d) {
3533
+ var agents = (d && d.agents) || [];
3534
+ var det = d.detected || 0;
3535
+ var cfg = d.configured || 0;
3536
+ setKPI('cov-detected', det);
3537
+ setKPI('cov-configured', cfg);
3538
+ var unprot = Math.max(0, det - cfg);
3539
+ setKPI('cov-unprotected', unprot);
3540
+ var ue = document.getElementById('cov-unprotected');
3541
+ if (ue) ue.style.color = unprot > 0 ? 'var(--warn)' : 'var(--text-muted)';
3542
+ if (!agents.length) {
3543
+ if (ld)
3544
+ ld.innerHTML =
3545
+ '<span style="color:var(--text-muted)">No agent platforms detected. Install ' +
3546
+ 'one (Claude Code, Cursor, Cline…), then run <code>pga init</code>.</span>';
3547
+ if (tbl) tbl.style.display = 'none';
3548
+ return;
3549
+ }
3550
+ if (ld) ld.style.display = 'none';
3551
+ if (tbl) tbl.style.display = '';
3552
+ tb.innerHTML = '';
3553
+ agents.forEach(function (a) {
3554
+ var status = !a.detected
3555
+ ? '<span style="color:var(--text-muted)">not installed</span>'
3556
+ : a.alreadyConfigured
3557
+ ? '<span style="color:var(--emerald)">● protected</span>'
3558
+ : '<span style="color:var(--warn)">○ unprotected — run <code>pga init</code></span>';
3559
+ var tr = document.createElement('tr');
3560
+ tr.innerHTML = '<td>' + esc(a.name || a.id || '') + '</td><td>' + status + '</td>';
3561
+ tb.appendChild(tr);
3562
+ });
3563
+ })
3564
+ .catch(function () {
3565
+ if (ld)
3566
+ ld.innerHTML =
3567
+ '<span style="color:var(--text-muted)">Platform detection unavailable.</span>';
3568
+ });
3569
+ }
3570
+ /* Runtime — surfaces /api/proxy-verdicts (live MCP proxy decisions). Quiet
3571
+ when healthy; a row only appears on a real allow/deny/ask evaluation. */
3572
+ function loadRuntime() {
3573
+ var ld = document.getElementById('rt-loading');
3574
+ var emp = document.getElementById('rt-empty');
3575
+ var tbl = document.getElementById('rt-table');
3576
+ var tb = document.getElementById('rt-tb');
3577
+ if (!tb) return;
3578
+ af('/api/proxy-verdicts')
3579
+ .then(function (r) {
3580
+ return r.json();
3581
+ })
3582
+ .then(function (d) {
3583
+ var vs = (d && d.verdicts) || [];
3584
+ if (ld) ld.style.display = 'none';
3585
+ if (!vs.length) {
3586
+ if (emp) emp.style.display = '';
3587
+ if (tbl) tbl.style.display = 'none';
3588
+ return;
3589
+ }
3590
+ if (emp) emp.style.display = 'none';
3591
+ if (tbl) tbl.style.display = '';
3592
+ tb.innerHTML = '';
3593
+ vs.forEach(function (v) {
3594
+ var oc = (v.outcome || '').toLowerCase();
3595
+ var color =
3596
+ oc === 'deny' ? 'var(--danger)' : oc === 'ask' ? 'var(--warn)' : 'var(--emerald)';
3597
+ var t = v.timestamp ? (String(v.timestamp).split('T')[1] || '').split('.')[0] : '--';
3598
+ var reason =
3599
+ v.reason || (v.matchedRules && v.matchedRules.join(', ')) || v.tool || '';
3600
+ var conf = typeof v.confidence === 'number' ? v.confidence + '%' : '--';
3601
+ var tr = document.createElement('tr');
3602
+ tr.innerHTML =
3603
+ '<td style="font-size:12px">' +
3604
+ esc(t) +
3605
+ '</td><td><span style="color:' +
3606
+ color +
3607
+ ';text-transform:uppercase;font-weight:600;font-size:12px">' +
3608
+ esc(oc || '?') +
3609
+ '</span></td><td style="font-size:12px">' +
3610
+ esc(String(reason).slice(0, 80)) +
3611
+ '</td><td style="font-size:12px">' +
3612
+ esc(conf) +
3613
+ '</td>';
3614
+ tb.appendChild(tr);
3615
+ });
3616
+ })
3617
+ .catch(function () {
3618
+ if (ld) ld.style.display = 'none';
3619
+ if (emp) emp.style.display = '';
3620
+ });
3621
+ }
1889
3622
  function loadSkills() {
3623
+ loadQuarantined();
1890
3624
  af('/api/installed-skills')
1891
3625
  .then(function (r) {
1892
3626
  return r.json();
@@ -1896,6 +3630,7 @@
1896
3630
  var tbl = document.getElementById('sk-table');
1897
3631
  var tb = document.getElementById('sk-tb');
1898
3632
  tb.innerHTML = '';
3633
+ skillsData = d.skills || [];
1899
3634
  if (!d.skills || d.skills.length === 0) {
1900
3635
  if (ld)
1901
3636
  ld.innerHTML = '<span style="color:var(--text-muted)">No skills detected.</span>';
@@ -1917,6 +3652,29 @@
1917
3652
  : status === 'blocked'
1918
3653
  ? 'badge-critical'
1919
3654
  : 'badge-w';
3655
+ var actBtn =
3656
+ 'padding:3px 9px;font-size:11px;border-radius:6px;cursor:pointer;background:transparent;';
3657
+ var actionsCell = s.whitelisted
3658
+ ? '<td style="white-space:nowrap">' +
3659
+ '<span style="color:var(--text-muted);font-size:12px;margin-right:8px">trusted</span>' +
3660
+ '<button type="button" class="act" data-act="unwhitelist" data-idx="' +
3661
+ i +
3662
+ '" style="' +
3663
+ actBtn +
3664
+ 'border:0.5px solid var(--border);color:var(--text-secondary)">Un-trust</button>' +
3665
+ '</td>'
3666
+ : '<td style="white-space:nowrap">' +
3667
+ '<button type="button" class="act" data-act="quarantine" data-idx="' +
3668
+ i +
3669
+ '" style="' +
3670
+ actBtn +
3671
+ 'border:0.5px solid var(--bad,#c0504d);color:var(--bad,#c0504d)">Quarantine</button> ' +
3672
+ '<button type="button" class="act" data-act="whitelist" data-idx="' +
3673
+ i +
3674
+ '" style="' +
3675
+ actBtn +
3676
+ 'border:0.5px solid var(--border);color:var(--text-secondary)">Mark safe</button>' +
3677
+ '</td>';
1920
3678
  var tr = document.createElement('tr');
1921
3679
  tr.innerHTML =
1922
3680
  '<td>' +
@@ -1934,7 +3692,8 @@
1934
3692
  badgeCls +
1935
3693
  '">' +
1936
3694
  esc(status) +
1937
- '</span></td>';
3695
+ '</span></td>' +
3696
+ actionsCell;
1938
3697
  tb.appendChild(tr);
1939
3698
  });
1940
3699
  setKPI('sk-total', d.skills.length);
@@ -1955,13 +3714,24 @@
1955
3714
  .then(function (d) {
1956
3715
  document.getElementById('tc-loading').style.display = 'none';
1957
3716
  document.getElementById('tc-content').style.display = '';
1958
- var isConnected = !!d.connected;
3717
+ // Three honest states: sharing OFF by choice is "Off" (neutral), NOT a
3718
+ // red "Disconnected" failure. Red is reserved for enabled-but-unreachable.
1959
3719
  var dot = document.getElementById('tc-conn-dot');
1960
- if (dot) dot.className = 'dot ' + (isConnected ? 'dot-ok' : 'dot-bad');
1961
3720
  var ctext = document.getElementById('tc-conn-text');
3721
+ var connState = !d.uploadEnabled
3722
+ ? { label: 'Off', cls: 'dot-off', color: 'var(--text-muted)' }
3723
+ : d.connected
3724
+ ? { label: 'Connected', cls: 'dot-ok', color: 'var(--ok)' }
3725
+ : { label: 'Not reachable', cls: 'dot-bad', color: 'var(--danger)' };
3726
+ if (dot) dot.className = 'dot ' + connState.cls;
1962
3727
  if (ctext) {
1963
- ctext.textContent = isConnected ? 'Connected' : 'Disconnected';
1964
- ctext.style.color = isConnected ? 'var(--ok)' : 'var(--danger)';
3728
+ ctext.textContent = connState.label;
3729
+ ctext.style.color = connState.color;
3730
+ ctext.title = !d.uploadEnabled
3731
+ ? 'Collective defense is off — your endpoint is not sharing or syncing. Turn it on below.'
3732
+ : d.connected
3733
+ ? 'Connected to the Threat Cloud relay.'
3734
+ : 'Collective defense is on but the relay is not reachable right now.';
1965
3735
  }
1966
3736
  document.getElementById('tc-endpoint').textContent = d.endpoint || '--';
1967
3737
  setKPI('tc-recv', d.totalRulesReceived || 0);
@@ -1974,19 +3744,35 @@
1974
3744
 
1975
3745
  var tog = document.getElementById('tc-toggle');
1976
3746
  var tlbl = document.getElementById('tc-toggle-label');
3747
+ var tsub = document.getElementById('tc-toggle-sub');
1977
3748
  if (d.uploadEnabled) {
1978
- if (tog) tog.classList.add('on');
3749
+ if (tog) {
3750
+ tog.classList.add('on');
3751
+ tog.setAttribute('aria-checked', 'true');
3752
+ }
1979
3753
  if (tlbl) {
1980
- tlbl.textContent = 'Enabled';
3754
+ tlbl.textContent = 'Collective defense is ON';
1981
3755
  tlbl.style.color = 'var(--ok)';
1982
3756
  }
3757
+ if (tsub)
3758
+ tsub.textContent =
3759
+ 'You are helping defend the commons. Click to turn off — sharing stops immediately.';
1983
3760
  } else {
1984
- if (tog) tog.classList.remove('on');
3761
+ if (tog) {
3762
+ tog.classList.remove('on');
3763
+ tog.setAttribute('aria-checked', 'false');
3764
+ }
1985
3765
  if (tlbl) {
1986
- tlbl.textContent = 'Disabled';
3766
+ tlbl.textContent = 'Collective defense is OFF';
1987
3767
  tlbl.style.color = 'var(--text-muted)';
1988
3768
  }
3769
+ if (tsub)
3770
+ tsub.textContent =
3771
+ 'Nothing leaves your machine. Click to turn on and share anonymized signatures.';
1989
3772
  }
3773
+ // Keep the CLI command in sync with the action the toggle will take.
3774
+ var cli = document.getElementById('tc-cli-cmd');
3775
+ if (cli) cli.textContent = 'pga config set telemetry ' + (d.uploadEnabled ? 'false' : 'true');
1990
3776
  })
1991
3777
  .catch(function () {
1992
3778
  document.getElementById('tc-loading').innerHTML =
@@ -1996,17 +3782,36 @@
1996
3782
  function toggleUpload() {
1997
3783
  var tog = document.getElementById('tc-toggle');
1998
3784
  var isOn = tog.classList.contains('on');
3785
+ // consentGiven flips collective defense BOTH on and off: the backend syncs
3786
+ // telemetryEnabled + threatCloudUploadEnabled and records/clears the consent
3787
+ // marker. Reversible either direction. Turning ON is the higher-consequence
3788
+ // direction (anonymized signatures start leaving the machine), so confirm it;
3789
+ // turning OFF is the safe direction and stays one click.
3790
+ if (!isOn) {
3791
+ var ok = window.confirm(
3792
+ 'Turn on collective defense?\n\n' +
3793
+ 'When Guard blocks an attack on this machine, it will share an anonymized signature ' +
3794
+ '(the matched rule ID + a one-way hash of the payload + the source type) with the ATR ' +
3795
+ 'network.\n\nNever shared: your prompts, code, file contents, keys, IP address, or ' +
3796
+ 'hostname.\n\nYou can turn this off anytime.'
3797
+ );
3798
+ if (!ok) return;
3799
+ }
1999
3800
  af('/api/threat-cloud', {
2000
3801
  method: 'POST',
2001
3802
  headers: { 'Content-Type': 'application/json' },
2002
- body: JSON.stringify({ uploadEnabled: !isOn }),
3803
+ body: JSON.stringify({ consentGiven: !isOn }),
2003
3804
  })
2004
3805
  .then(function (r) {
2005
3806
  return r.json();
2006
3807
  })
2007
3808
  .then(function (d) {
2008
3809
  if (d.success) {
2009
- toast('Saved');
3810
+ toast(
3811
+ !isOn
3812
+ ? 'Collective defense ON — anonymized signatures will be shared.'
3813
+ : 'Collective defense OFF — nothing leaves this machine.'
3814
+ );
2010
3815
  loadTCloud();
2011
3816
  } else {
2012
3817
  toast(d.error || 'Error');
@@ -2017,47 +3822,216 @@
2017
3822
  });
2018
3823
  }
2019
3824
 
3825
+ /* Guard mode is THREE states (learning / report-only / protection). The
3826
+ old binary toggle could not represent report-only and silently rewrote
3827
+ it to 'learning' on save — quietly downgrading enforcement. This 3-state
3828
+ segmented control preserves whatever the config actually is. */
3829
+ var selectedMode = 'learning';
3830
+ var _modePendingRestart = false;
3831
+ var MODE_INFO = {
3832
+ learning: {
3833
+ desc: 'builds a baseline of normal activity and only logs — no blocking.',
3834
+ color: 'var(--warn)',
3835
+ },
3836
+ 'report-only': {
3837
+ desc: 'detects and records everything but never acts (recommended for new deployments).',
3838
+ color: 'var(--sage, #8b9a8e)',
3839
+ },
3840
+ protection: {
3841
+ desc: 'actively blocks detected threats per your enforcement policy.',
3842
+ color: 'var(--ok)',
3843
+ },
3844
+ };
3845
+ function renderModeSeg() {
3846
+ var seg = document.getElementById('st-mode-seg');
3847
+ if (!seg) return;
3848
+ Array.prototype.forEach.call(seg.querySelectorAll('.seg-btn'), function (b) {
3849
+ var on = b.getAttribute('data-mode') === selectedMode;
3850
+ b.style.background = on ? 'var(--surface-3, #302b27)' : 'transparent';
3851
+ b.style.color = on
3852
+ ? MODE_INFO[selectedMode]
3853
+ ? MODE_INFO[selectedMode].color
3854
+ : 'var(--text)'
3855
+ : 'var(--text-muted)';
3856
+ b.setAttribute('aria-checked', on ? 'true' : 'false');
3857
+ });
3858
+ var d = document.getElementById('st-mode-desc');
3859
+ if (d && MODE_INFO[selectedMode]) {
3860
+ if (_modePendingRestart && selectedMode === 'protection') {
3861
+ // Preserve the pending-restart note across loadSettings() re-renders
3862
+ // so the OS-response caveat does not vanish on the next refresh.
3863
+ d.innerHTML =
3864
+ 'This mode ' +
3865
+ esc(MODE_INFO[selectedMode].desc) +
3866
+ ' <span style="color:var(--warn)">Pending restart: automatic OS responses arm after ' +
3867
+ 'you restart Guard (<code>pga guard restart</code>).</span>';
3868
+ } else {
3869
+ d.textContent = 'This mode ' + MODE_INFO[selectedMode].desc;
3870
+ }
3871
+ }
3872
+ }
3873
+ function selectMode(m) {
3874
+ if (!MODE_INFO[m]) return;
3875
+ // A fresh selection clears any prior pending-restart note; it re-arms
3876
+ // only after the next protection-mode save.
3877
+ if (m !== selectedMode) _modePendingRestart = false;
3878
+ selectedMode = m;
3879
+ renderModeSeg();
3880
+ }
3881
+ /* Optional alert channels. The dashboard NEVER sets channel secrets over
3882
+ HTTP (that was removed as an exfil surface). For each channel that Guard
3883
+ actually supports (NotificationConfig in types.ts: email / slack /
3884
+ telegram / line / webhook) it shows what the channel is, whether it is
3885
+ configured, and the exact JSON to paste into config.json. Placeholders
3886
+ only — the user fills in their own secret in the file, not here. */
3887
+ var NOTIFY_CHANNELS = [
3888
+ {
3889
+ key: 'slack',
3890
+ name: 'Slack',
3891
+ what: 'Post each alert into a Slack channel using an Incoming Webhook URL from Slack.',
3892
+ snippet: '"slack": { "webhookUrl": "https://hooks.slack.com/services/XXX/YYY/ZZZ" }',
3893
+ },
3894
+ {
3895
+ key: 'telegram',
3896
+ name: 'Telegram',
3897
+ what: 'Message you (or a group) through a Telegram bot. Needs the bot token and chat ID.',
3898
+ snippet: '"telegram": { "botToken": "YOUR_BOT_TOKEN", "chatId": "YOUR_CHAT_ID" }',
3899
+ },
3900
+ {
3901
+ key: 'email',
3902
+ name: 'Email',
3903
+ what: 'Email each alert through your own SMTP server.',
3904
+ snippet:
3905
+ '"email": { "host": "smtp.example.com", "port": 587, "secure": false, "auth": { "user": "YOU", "pass": "YOUR_SMTP_PASSWORD" }, "from": "guard@example.com", "to": ["you@example.com"] }',
3906
+ },
3907
+ {
3908
+ key: 'line',
3909
+ name: 'LINE',
3910
+ what: 'Send each alert to LINE using a LINE Notify access token.',
3911
+ snippet: '"line": { "accessToken": "YOUR_LINE_NOTIFY_TOKEN" }',
3912
+ },
3913
+ {
3914
+ key: 'webhook',
3915
+ name: 'Webhook',
3916
+ what: 'POST each alert as JSON to any URL you own (wire it into your own tooling).',
3917
+ snippet: '"webhook": { "url": "https://example.com/guard-hook", "secret": "OPTIONAL_SHARED_SECRET" }',
3918
+ },
3919
+ ];
3920
+ var notifySnippetSeq = 0;
3921
+ function renderNotifyChannels(n) {
3922
+ n = n || {};
3923
+ var el = document.getElementById('st-notify-list');
3924
+ if (!el) return;
3925
+ // If the user already configured a channel, open the optional section so
3926
+ // they can see/manage it; otherwise keep it collapsed (zero-config default).
3927
+ var anyOn = NOTIFY_CHANNELS.some(function (c) {
3928
+ return !!n[c.key];
3929
+ });
3930
+ var details = document.getElementById('notify-details');
3931
+ if (details && anyOn) details.setAttribute('open', '');
3932
+ el.innerHTML = NOTIFY_CHANNELS.map(function (c) {
3933
+ var on = !!n[c.key];
3934
+ var stateHtml = on
3935
+ ? '<span class="nc-state" style="color:var(--ok)">configured</span>'
3936
+ : '<span class="nc-state" style="color:var(--text-muted)">not set up</span>';
3937
+ var cmdId = 'nc-cmd-' + ++notifySnippetSeq;
3938
+ return (
3939
+ '<div class="nc">' +
3940
+ '<div class="nc-head"><span class="nc-name">' +
3941
+ esc(c.name) +
3942
+ '</span>' +
3943
+ stateHtml +
3944
+ '</div>' +
3945
+ '<div class="nc-what">' +
3946
+ esc(c.what) +
3947
+ '</div>' +
3948
+ '<div class="cmd-box">' +
3949
+ '<code id="' +
3950
+ cmdId +
3951
+ '">' +
3952
+ esc(c.snippet) +
3953
+ '</code>' +
3954
+ '<button class="cmd-copy" type="button" data-copy-target="' +
3955
+ cmdId +
3956
+ '">Copy</button>' +
3957
+ '</div>' +
3958
+ '</div>'
3959
+ );
3960
+ }).join('');
3961
+ el.innerHTML +=
3962
+ '<div style="font-size:12px;color:var(--text-muted);margin-top:2px">' +
3963
+ 'Add the line inside the <code>"notifications"</code> object in ' +
3964
+ '<code>~/.panguard-guard/config.json</code>, then restart Guard. ' +
3965
+ 'Without any channel, alerts still appear in this dashboard.</div>';
3966
+ }
3967
+ /* Read-only enforcement-policy (Protection-mode blast radius). Shows which
3968
+ automatic destructive responses are armed, so enabling Protection is not
3969
+ a black box. Configured in config.json, not here. */
3970
+ function renderEnforcement(p) {
3971
+ var el = document.getElementById('st-enforce-list');
3972
+ if (!el) return;
3973
+ if (!p) {
3974
+ el.innerHTML =
3975
+ '<div style="color:var(--text-muted)">Default — detections are logged and you are ' +
3976
+ 'notified; no automatic destructive action is taken.</div>';
3977
+ return;
3978
+ }
3979
+ var acts = [
3980
+ ['blockIPs', 'Block attacker IPs'],
3981
+ ['killProcesses', 'Kill malicious processes'],
3982
+ ['isolateFiles', 'Isolate / quarantine files'],
3983
+ ['disableAccounts', 'Disable accounts'],
3984
+ ];
3985
+ el.innerHTML = acts
3986
+ .map(function (a) {
3987
+ var on = p[a[0]] && p[a[0]].enabled;
3988
+ return (
3989
+ '<div>' +
3990
+ (on
3991
+ ? '<span style="color:var(--danger)">● armed</span>'
3992
+ : '<span style="color:var(--text-muted)">○ off</span>') +
3993
+ ' &mdash; ' +
3994
+ esc(a[1]) +
3995
+ '</div>'
3996
+ );
3997
+ })
3998
+ .join('');
3999
+ }
2020
4000
  function loadSettings() {
4001
+ // Reflect Layer C connected/off state when the tab is opened cold, from
4002
+ // the last live status (the layer state rides on /api/status, not /api/config).
4003
+ if (_lastStatus) renderLayerCSettings(_lastStatus.layers);
2021
4004
  af('/api/config')
2022
4005
  .then(function (r) {
2023
4006
  return r.json();
2024
4007
  })
2025
4008
  .then(function (cfg) {
2026
4009
  if (!cfg) return;
2027
- var mt = document.getElementById('st-mode-toggle');
2028
- var ml = document.getElementById('st-mode-label');
2029
- var isProt = cfg.mode === 'protection';
2030
- if (mt) {
2031
- if (isProt) mt.classList.add('on');
2032
- else mt.classList.remove('on');
2033
- }
2034
- if (ml) {
2035
- ml.textContent = isProt ? 'Protection' : 'Learning';
2036
- ml.style.color = isProt ? 'var(--ok)' : 'var(--warn)';
2037
- }
4010
+ // Preserve the real mode — including report-only — instead of
4011
+ // collapsing it to a binary on/off.
4012
+ selectedMode =
4013
+ cfg.mode === 'protection' || cfg.mode === 'report-only' || cfg.mode === 'learning'
4014
+ ? cfg.mode
4015
+ : 'learning';
4016
+ renderModeSeg();
2038
4017
  var ep = document.getElementById('st-tc-endpoint');
2039
4018
  if (ep) ep.value = cfg.threatCloudEndpoint || '';
4019
+ // Show the REAL port: the one this dashboard is actually served on
4020
+ // (location.port) is the ground truth a user needs to reconnect, not
4021
+ // a hard-coded fallback that can be flat wrong.
2040
4022
  var port = document.getElementById('st-port');
2041
- if (port) port.textContent = cfg.dashboardPort || 9999;
4023
+ if (port) port.textContent = cfg.dashboardPort || location.port || '3100';
4024
+ renderNotifyChannels(cfg.notifications || {});
4025
+ renderEnforcement(cfg.enforcementPolicy);
2042
4026
  })
2043
4027
  .catch(function () {
2044
4028
  /* ignore */
2045
4029
  });
2046
4030
  }
2047
- function toggleMode() {
2048
- var t = document.getElementById('st-mode-toggle');
2049
- t.classList.toggle('on');
2050
- var l = document.getElementById('st-mode-label');
2051
- var isProt = t.classList.contains('on');
2052
- if (l) {
2053
- l.textContent = isProt ? 'Protection' : 'Learning';
2054
- l.style.color = isProt ? 'var(--ok)' : 'var(--warn)';
2055
- }
2056
- }
2057
4031
  function saveSettings() {
2058
- var modeOn = document.getElementById('st-mode-toggle').classList.contains('on');
4032
+ var priorMode = selectedMode;
2059
4033
  var body = {
2060
- mode: modeOn ? 'protection' : 'learning',
4034
+ mode: selectedMode,
2061
4035
  endpoint: document.getElementById('st-tc-endpoint').value || undefined,
2062
4036
  };
2063
4037
  af('/api/threat-cloud', {
@@ -2070,16 +4044,41 @@
2070
4044
  })
2071
4045
  .then(function (d) {
2072
4046
  if (d.success) {
2073
- toast('Settings saved restart Guard to apply a mode change');
4047
+ // The daemon now live-applies the change (detection re-arms at
4048
+ // once), so loadSettings() re-reads the NEW mode — the control no
4049
+ // longer snaps back. Protection mode's OS-level response actions
4050
+ // still need a restart, so surface that as a pending state rather
4051
+ // than implying nothing happened.
4052
+ if (body.mode === 'protection') {
4053
+ toast('Saved. Detection is live now; restart Guard to arm automatic responses.');
4054
+ showModePendingRestart();
4055
+ } else {
4056
+ toast('Saved — applied immediately.');
4057
+ }
2074
4058
  loadSettings();
2075
4059
  } else {
2076
4060
  toast(d.error || 'Save failed');
4061
+ // Revert the optimistic selection only when the save was rejected.
4062
+ selectMode(priorMode);
2077
4063
  }
2078
4064
  })
2079
4065
  .catch(function () {
2080
4066
  toast('Network error');
2081
4067
  });
2082
4068
  }
4069
+ /* Keep the optimistic mode value and show a clear "pending restart" note
4070
+ for the part that genuinely needs a restart (OS-level responses) —
4071
+ never snap the control back to the old value. */
4072
+ function showModePendingRestart() {
4073
+ _modePendingRestart = true;
4074
+ var d = document.getElementById('st-mode-desc');
4075
+ if (d) {
4076
+ d.innerHTML =
4077
+ (MODE_INFO[selectedMode] ? 'This mode ' + esc(MODE_INFO[selectedMode].desc) : '') +
4078
+ ' <span style="color:var(--warn)">Pending restart: automatic OS responses arm after ' +
4079
+ 'you restart Guard (<code>pga guard restart</code>).</span>';
4080
+ }
4081
+ }
2083
4082
 
2084
4083
  /* ============================================================ */
2085
4084
  /* Event wiring (CSP-safe: no inline handlers) */
@@ -2088,12 +4087,165 @@
2088
4087
  var el = document.getElementById(id);
2089
4088
  if (el) el.addEventListener(ev, fn);
2090
4089
  }
4090
+ function skillAction(act, idx) {
4091
+ var s = skillsData[idx];
4092
+ if (!s || !s.name) return;
4093
+ var name = s.name;
4094
+ if (
4095
+ act === 'quarantine' &&
4096
+ !window.confirm(
4097
+ 'Quarantine "' +
4098
+ name +
4099
+ '"?\n\n' +
4100
+ 'This moves the skill file out of your agent’s reach, so the agent can no longer use ' +
4101
+ 'it. The file is NOT deleted — it moves to ~/.panguard/quarantine and you can restore ' +
4102
+ 'it anytime from the Quarantined Files list below.'
4103
+ )
4104
+ ) {
4105
+ return;
4106
+ }
4107
+ if (
4108
+ act === 'whitelist' &&
4109
+ !window.confirm(
4110
+ 'Mark "' +
4111
+ name +
4112
+ '" as safe?\n\n' +
4113
+ 'Guard will trust this skill and stop flagging it for behavior drift — this reduces ' +
4114
+ 'monitoring for it. Only do this for skills you installed and trust. You can undo it ' +
4115
+ 'later with Un-trust.'
4116
+ )
4117
+ ) {
4118
+ return;
4119
+ }
4120
+ var EPMAP = {
4121
+ quarantine: '/api/skills/quarantine',
4122
+ whitelist: '/api/skills/whitelist',
4123
+ unwhitelist: '/api/skills/unwhitelist',
4124
+ };
4125
+ var ep = EPMAP[act];
4126
+ if (!ep) return;
4127
+ af(ep, {
4128
+ method: 'POST',
4129
+ headers: { 'Content-Type': 'application/json' },
4130
+ body: JSON.stringify({ name: name }),
4131
+ })
4132
+ .then(function (r) {
4133
+ return r.json().then(function (d) {
4134
+ return { ok: r.ok, d: d };
4135
+ });
4136
+ })
4137
+ .then(function (res) {
4138
+ if (res.ok && res.d && res.d.success) {
4139
+ var msg =
4140
+ act === 'quarantine'
4141
+ ? 'Quarantined ' + name + ' — moved out of the agent’s reach (restorable below).'
4142
+ : act === 'unwhitelist'
4143
+ ? 'Un-trusted ' + name + ' — Guard will evaluate it again like any unknown skill.'
4144
+ : 'Marked safe — Guard will stop flagging ' + name + '.';
4145
+ toast(msg);
4146
+ loadSkills();
4147
+ loadQuarantined();
4148
+ } else {
4149
+ toast((res.d && res.d.error) || 'Action failed');
4150
+ }
4151
+ })
4152
+ .catch(function () {
4153
+ toast('Action failed');
4154
+ });
4155
+ }
4156
+ /* Quarantined-files list + restore — honors the 'can be restored' promise
4157
+ the quarantine dialog makes. Reads /api/skills/quarantined; Restore POSTs
4158
+ /api/skills/restore. Hidden when nothing is quarantined. */
4159
+ function loadQuarantined() {
4160
+ var sec = document.getElementById('qz-section');
4161
+ var tb = document.getElementById('qz-tb');
4162
+ if (!sec || !tb) return;
4163
+ af('/api/skills/quarantined')
4164
+ .then(function (r) {
4165
+ return r.json();
4166
+ })
4167
+ .then(function (d) {
4168
+ var recs = (d && d.records) || [];
4169
+ if (!recs.length) {
4170
+ sec.style.display = 'none';
4171
+ tb.innerHTML = '';
4172
+ return;
4173
+ }
4174
+ sec.style.display = '';
4175
+ tb.innerHTML = '';
4176
+ recs.forEach(function (rec) {
4177
+ var when = rec.quarantinedAt ? new Date(rec.quarantinedAt).toLocaleString() : '--';
4178
+ var tr = document.createElement('tr');
4179
+ tr.innerHTML =
4180
+ '<td class="mono" style="font-size:12px">' +
4181
+ esc(rec.originalPath || '') +
4182
+ '</td>' +
4183
+ '<td style="font-size:12px">' +
4184
+ esc(rec.reason || '') +
4185
+ '</td>' +
4186
+ '<td style="font-size:12px">' +
4187
+ esc(when) +
4188
+ '</td>' +
4189
+ '<td style="white-space:nowrap">' +
4190
+ '<button type="button" class="act" data-restore-id="' +
4191
+ esc(rec.id || '') +
4192
+ '" style="padding:3px 9px;font-size:11px;border-radius:6px;cursor:pointer;' +
4193
+ 'background:transparent;border:0.5px solid var(--emerald,#34d399);' +
4194
+ 'color:var(--emerald,#34d399)">Restore</button></td>';
4195
+ tb.appendChild(tr);
4196
+ });
4197
+ })
4198
+ .catch(function () {
4199
+ sec.style.display = 'none';
4200
+ });
4201
+ }
4202
+ function restoreQuarantined(id) {
4203
+ if (!id) return;
4204
+ af('/api/skills/restore', {
4205
+ method: 'POST',
4206
+ headers: { 'Content-Type': 'application/json' },
4207
+ body: JSON.stringify({ id: id }),
4208
+ })
4209
+ .then(function (r) {
4210
+ return r.json().then(function (d) {
4211
+ return { ok: r.ok, d: d };
4212
+ });
4213
+ })
4214
+ .then(function (res) {
4215
+ if (res.ok && res.d && res.d.success) {
4216
+ toast('Restored');
4217
+ loadQuarantined();
4218
+ loadSkills();
4219
+ } else {
4220
+ toast((res.d && res.d.error) || 'Restore failed');
4221
+ }
4222
+ })
4223
+ .catch(function () {
4224
+ toast('Restore failed');
4225
+ });
4226
+ }
2091
4227
  function wireEvents() {
2092
4228
  document.querySelectorAll('.fb').forEach(function (b) {
2093
4229
  b.addEventListener('click', function (e) {
2094
4230
  filterThreats(b.getAttribute('data-sev'), e);
2095
4231
  });
2096
4232
  });
4233
+ var skTb = document.getElementById('sk-tb');
4234
+ if (skTb) {
4235
+ skTb.addEventListener('click', function (e) {
4236
+ var btn = e.target && e.target.closest ? e.target.closest('button.act') : null;
4237
+ if (!btn) return;
4238
+ skillAction(btn.getAttribute('data-act'), parseInt(btn.getAttribute('data-idx'), 10));
4239
+ });
4240
+ }
4241
+ var qzTb = document.getElementById('qz-tb');
4242
+ if (qzTb) {
4243
+ qzTb.addEventListener('click', function (e) {
4244
+ var btn =
4245
+ e.target && e.target.closest ? e.target.closest('button[data-restore-id]') : null;
4246
+ if (btn) restoreQuarantined(btn.getAttribute('data-restore-id'));
4247
+ });
4248
+ }
2097
4249
  document.querySelectorAll('th[data-sort]').forEach(function (th) {
2098
4250
  th.addEventListener('click', function () {
2099
4251
  sortThreats(th.getAttribute('data-sort'));
@@ -2119,7 +4271,72 @@
2119
4271
  toast('Refreshing skills…');
2120
4272
  });
2121
4273
  on('tc-toggle', 'click', toggleUpload);
2122
- on('st-mode-toggle', 'click', toggleMode);
4274
+ // The toggle is a role=switch — make it keyboard-operable (Space/Enter).
4275
+ on('tc-toggle', 'keydown', function (e) {
4276
+ if (e.key === ' ' || e.key === 'Enter') {
4277
+ e.preventDefault();
4278
+ toggleUpload();
4279
+ }
4280
+ });
4281
+ // Delegated copy-to-clipboard for every [data-copy-target] command box.
4282
+ document.addEventListener('click', function (e) {
4283
+ var btn = e.target && e.target.closest ? e.target.closest('[data-copy-target]') : null;
4284
+ if (!btn) return;
4285
+ var src = document.getElementById(btn.getAttribute('data-copy-target'));
4286
+ if (!src) return;
4287
+ copyText(src.textContent || '', btn);
4288
+ });
4289
+ var modeSeg = document.getElementById('st-mode-seg');
4290
+ if (modeSeg) {
4291
+ modeSeg.addEventListener('click', function (e) {
4292
+ var btn = e.target && e.target.closest ? e.target.closest('button.seg-btn') : null;
4293
+ if (btn) selectMode(btn.getAttribute('data-mode'));
4294
+ });
4295
+ }
4296
+ var postureDed = document.getElementById('posture-deductions');
4297
+ if (postureDed) {
4298
+ postureDed.addEventListener('click', function (e) {
4299
+ var btn = e.target && e.target.closest ? e.target.closest('button[data-goto]') : null;
4300
+ if (btn) nav(btn.getAttribute('data-goto'));
4301
+ });
4302
+ }
4303
+ on('conn-copy', 'click', function () {
4304
+ copyText('pga up', document.getElementById('conn-copy'));
4305
+ });
4306
+ // Layer C CTA on Overview deep-links to the Settings section.
4307
+ on('lc-cta-btn', 'click', function () {
4308
+ nav('settings');
4309
+ setTimeout(function () {
4310
+ var s = document.getElementById('p-settings-layerc');
4311
+ if (s) s.scrollIntoView({ behavior: 'smooth', block: 'start' });
4312
+ }, 60);
4313
+ });
4314
+ // Welcome guide: "Got it"/X persist the dismissal; "Dismiss for now" returns
4315
+ // next launch; the sidebar link reopens it. Its [data-goto] button is not
4316
+ // covered by the posture-deductions handler, so delegate nav here.
4317
+ on('welcome-done', 'click', function () {
4318
+ hideWelcome(true);
4319
+ });
4320
+ on('welcome-x', 'click', function () {
4321
+ hideWelcome(true);
4322
+ });
4323
+ on('welcome-later', 'click', function () {
4324
+ hideWelcome(false);
4325
+ });
4326
+ on('welcome-reopen', 'click', reopenWelcome);
4327
+ on('welcome-reopen', 'keydown', function (e) {
4328
+ if (e.key === ' ' || e.key === 'Enter') {
4329
+ e.preventDefault();
4330
+ reopenWelcome();
4331
+ }
4332
+ });
4333
+ var wg = document.getElementById('welcome-guide');
4334
+ if (wg) {
4335
+ wg.addEventListener('click', function (e) {
4336
+ var b = e.target && e.target.closest ? e.target.closest('button[data-goto]') : null;
4337
+ if (b) nav(b.getAttribute('data-goto'));
4338
+ });
4339
+ }
2123
4340
  on('btn-save', 'click', saveSettings);
2124
4341
  on('btn-reload', 'click', loadSettings);
2125
4342
  }
@@ -2128,12 +4345,18 @@
2128
4345
  /* Boot */
2129
4346
  /* ============================================================ */
2130
4347
  (function () {
4348
+ setConnState('connecting');
2131
4349
  wireEvents();
2132
4350
  connectWS();
2133
4351
  loadStatus();
2134
4352
  loadThreats();
2135
4353
  // Mark default nav active
2136
4354
  nav('overview');
4355
+ // Fallback: if neither the WS push nor the initial fetch yields a live
4356
+ // status within a few seconds, Guard is almost certainly not running.
4357
+ _connectTimer = setTimeout(function () {
4358
+ if (_connState !== 'live' && !_unauthShown) setConnState('down');
4359
+ }, 6000);
2137
4360
  })();
2138
4361
  </script>
2139
4362
  </body>