@permission-protocol/mcp-guard 0.1.0 → 0.2.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 (88) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -59
  4. package/SECURITY.md +40 -0
  5. package/dist/src/approval-server.d.ts +69 -3
  6. package/dist/src/approval-server.js +345 -86
  7. package/dist/src/approval-server.js.map +1 -1
  8. package/dist/src/approval-ui.d.ts +5 -0
  9. package/dist/src/approval-ui.js +120 -81
  10. package/dist/src/approval-ui.js.map +1 -1
  11. package/dist/src/classify.d.ts +29 -0
  12. package/dist/src/classify.js +107 -0
  13. package/dist/src/classify.js.map +1 -0
  14. package/dist/src/cli.js +49 -12
  15. package/dist/src/cli.js.map +1 -1
  16. package/dist/src/config.d.ts +21 -1
  17. package/dist/src/config.js +92 -5
  18. package/dist/src/config.js.map +1 -1
  19. package/dist/src/enforcement/index.d.ts +12 -0
  20. package/dist/src/enforcement/index.js +53 -0
  21. package/dist/src/enforcement/index.js.map +1 -0
  22. package/dist/src/enforcement/policy.d.ts +29 -0
  23. package/dist/src/enforcement/policy.js +60 -0
  24. package/dist/src/enforcement/policy.js.map +1 -0
  25. package/dist/src/enforcement/types.d.ts +19 -0
  26. package/dist/src/enforcement/types.js +3 -0
  27. package/dist/src/enforcement/types.js.map +1 -0
  28. package/dist/src/enforcement/verifier.d.ts +4 -0
  29. package/dist/src/enforcement/verifier.js +110 -0
  30. package/dist/src/enforcement/verifier.js.map +1 -0
  31. package/dist/src/engine.d.ts +11 -2
  32. package/dist/src/engine.js +20 -14
  33. package/dist/src/engine.js.map +1 -1
  34. package/dist/src/frame.d.ts +67 -0
  35. package/dist/src/frame.js +113 -0
  36. package/dist/src/frame.js.map +1 -0
  37. package/dist/src/pending.d.ts +108 -12
  38. package/dist/src/pending.js +174 -24
  39. package/dist/src/pending.js.map +1 -1
  40. package/dist/src/proxy.d.ts +29 -2
  41. package/dist/src/proxy.js +274 -105
  42. package/dist/src/proxy.js.map +1 -1
  43. package/dist/src/receipt-verify.d.ts +36 -0
  44. package/dist/src/receipt-verify.js +88 -0
  45. package/dist/src/receipt-verify.js.map +1 -0
  46. package/dist/src/receipt.d.ts +81 -7
  47. package/dist/src/receipt.js +100 -17
  48. package/dist/src/receipt.js.map +1 -1
  49. package/dist/src/signer.d.ts +38 -0
  50. package/dist/src/signer.js +112 -0
  51. package/dist/src/signer.js.map +1 -0
  52. package/dist/src/verify-cli.d.ts +1 -0
  53. package/dist/src/verify-cli.js +133 -0
  54. package/dist/src/verify-cli.js.map +1 -0
  55. package/dist/src/verify-core.d.ts +22 -0
  56. package/dist/src/verify-core.js +50 -0
  57. package/dist/src/verify-core.js.map +1 -0
  58. package/dist/src/webhook.d.ts +35 -0
  59. package/dist/src/webhook.js +57 -0
  60. package/dist/src/webhook.js.map +1 -0
  61. package/example/test.sh +5 -7
  62. package/package.json +33 -7
  63. package/templates/github-actions/receipt-gate.yml +64 -0
  64. package/assets/demo.gif +0 -0
  65. package/demo/_demo-scenario.sh +0 -43
  66. package/dist/src/tests/config.test.d.ts +0 -1
  67. package/dist/src/tests/config.test.js +0 -63
  68. package/dist/src/tests/config.test.js.map +0 -1
  69. package/dist/src/tests/engine.test.d.ts +0 -1
  70. package/dist/src/tests/engine.test.js +0 -46
  71. package/dist/src/tests/engine.test.js.map +0 -1
  72. package/dist/src/tests/receipt.test.d.ts +0 -1
  73. package/dist/src/tests/receipt.test.js +0 -44
  74. package/dist/src/tests/receipt.test.js.map +0 -1
  75. package/docs/index.html +0 -771
  76. package/permission-protocol-mcp-guard-0.1.0.tgz +0 -0
  77. package/src/approval-server.ts +0 -136
  78. package/src/approval-ui.ts +0 -154
  79. package/src/cli.ts +0 -84
  80. package/src/config.ts +0 -68
  81. package/src/engine.ts +0 -31
  82. package/src/pending.ts +0 -65
  83. package/src/proxy.ts +0 -173
  84. package/src/receipt.ts +0 -52
  85. package/src/tests/config.test.ts +0 -61
  86. package/src/tests/engine.test.ts +0 -47
  87. package/src/tests/receipt.test.ts +0 -44
  88. package/tsconfig.json +0 -16
package/docs/index.html DELETED
@@ -1,771 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>MCP Guard — Receipt Viewer</title>
7
- <style>
8
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
-
10
- :root {
11
- --bg: #0d1117;
12
- --card-bg: #161b22;
13
- --border: #30363d;
14
- --text: #e6edf3;
15
- --text-muted: #8b949e;
16
- --text-subtle: #6e7681;
17
- --green: #2ea043;
18
- --green-bg: #0f2c1a;
19
- --green-text: #3fb950;
20
- --red: #da3633;
21
- --red-bg: #2c0f0f;
22
- --red-text: #f85149;
23
- --yellow: #9e6a03;
24
- --yellow-bg: #2c1f08;
25
- --yellow-text: #e3b341;
26
- --blue: #1f6feb;
27
- --blue-bg: #0d1d32;
28
- --blue-text: #58a6ff;
29
- --accent: #58a6ff;
30
- --radius: 8px;
31
- --radius-sm: 4px;
32
- }
33
-
34
- html { scroll-behavior: smooth; }
35
-
36
- body {
37
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
38
- background: var(--bg);
39
- color: var(--text);
40
- line-height: 1.6;
41
- min-height: 100vh;
42
- display: flex;
43
- flex-direction: column;
44
- }
45
-
46
- /* ── Header ── */
47
- header {
48
- border-bottom: 1px solid var(--border);
49
- padding: 16px 24px;
50
- display: flex;
51
- align-items: center;
52
- gap: 12px;
53
- position: sticky;
54
- top: 0;
55
- background: rgba(13, 17, 23, 0.92);
56
- backdrop-filter: blur(12px);
57
- z-index: 100;
58
- }
59
-
60
- .header-icon {
61
- width: 32px;
62
- height: 32px;
63
- background: linear-gradient(135deg, #1f6feb, #58a6ff);
64
- border-radius: 8px;
65
- display: flex;
66
- align-items: center;
67
- justify-content: center;
68
- font-size: 18px;
69
- flex-shrink: 0;
70
- }
71
-
72
- header h1 {
73
- font-size: 18px;
74
- font-weight: 600;
75
- color: var(--text);
76
- letter-spacing: -0.02em;
77
- }
78
-
79
- header h1 span {
80
- color: var(--text-muted);
81
- font-weight: 400;
82
- }
83
-
84
- .header-spacer { flex: 1; }
85
-
86
- .header-badge {
87
- font-size: 11px;
88
- font-weight: 600;
89
- padding: 2px 8px;
90
- border-radius: 20px;
91
- background: var(--blue-bg);
92
- color: var(--blue-text);
93
- border: 1px solid rgba(88, 166, 255, 0.2);
94
- letter-spacing: 0.04em;
95
- text-transform: uppercase;
96
- }
97
-
98
- /* ── Main layout ── */
99
- main {
100
- flex: 1;
101
- max-width: 960px;
102
- width: 100%;
103
- margin: 0 auto;
104
- padding: 32px 24px;
105
- }
106
-
107
- /* ── Input section ── */
108
- .input-section {
109
- margin-bottom: 32px;
110
- }
111
-
112
- .section-label {
113
- font-size: 12px;
114
- font-weight: 600;
115
- text-transform: uppercase;
116
- letter-spacing: 0.08em;
117
- color: var(--text-muted);
118
- margin-bottom: 10px;
119
- }
120
-
121
- .input-row {
122
- display: grid;
123
- grid-template-columns: 1fr auto;
124
- gap: 12px;
125
- align-items: start;
126
- }
127
-
128
- @media (max-width: 600px) {
129
- .input-row { grid-template-columns: 1fr; }
130
- }
131
-
132
- textarea {
133
- width: 100%;
134
- min-height: 120px;
135
- background: var(--card-bg);
136
- border: 1px solid var(--border);
137
- border-radius: var(--radius);
138
- color: var(--text);
139
- font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
140
- font-size: 12.5px;
141
- line-height: 1.5;
142
- padding: 12px 14px;
143
- resize: vertical;
144
- outline: none;
145
- transition: border-color 0.15s;
146
- }
147
-
148
- textarea::placeholder { color: var(--text-subtle); }
149
- textarea:focus { border-color: var(--accent); }
150
-
151
- .side-controls {
152
- display: flex;
153
- flex-direction: column;
154
- gap: 8px;
155
- min-width: 120px;
156
- }
157
-
158
- /* ── Drop zone ── */
159
- .drop-zone {
160
- border: 2px dashed var(--border);
161
- border-radius: var(--radius);
162
- padding: 20px 16px;
163
- text-align: center;
164
- cursor: pointer;
165
- transition: all 0.15s;
166
- color: var(--text-muted);
167
- font-size: 13px;
168
- background: var(--card-bg);
169
- position: relative;
170
- }
171
-
172
- .drop-zone:hover, .drop-zone.drag-over {
173
- border-color: var(--accent);
174
- background: var(--blue-bg);
175
- color: var(--blue-text);
176
- }
177
-
178
- .drop-zone input[type="file"] {
179
- position: absolute;
180
- inset: 0;
181
- opacity: 0;
182
- cursor: pointer;
183
- width: 100%;
184
- height: 100%;
185
- }
186
-
187
- .drop-icon { font-size: 22px; margin-bottom: 4px; }
188
- .drop-text { font-size: 12px; line-height: 1.4; }
189
-
190
- /* ── Buttons ── */
191
- .btn {
192
- display: inline-flex;
193
- align-items: center;
194
- justify-content: center;
195
- gap: 6px;
196
- padding: 8px 16px;
197
- border-radius: var(--radius-sm);
198
- font-size: 13px;
199
- font-weight: 500;
200
- cursor: pointer;
201
- border: 1px solid transparent;
202
- transition: all 0.15s;
203
- white-space: nowrap;
204
- width: 100%;
205
- }
206
-
207
- .btn-primary {
208
- background: #238636;
209
- border-color: #2ea043;
210
- color: #fff;
211
- }
212
- .btn-primary:hover { background: #2ea043; }
213
-
214
- .btn-secondary {
215
- background: transparent;
216
- border-color: var(--border);
217
- color: var(--text-muted);
218
- }
219
- .btn-secondary:hover {
220
- border-color: var(--text-muted);
221
- color: var(--text);
222
- }
223
-
224
- /* ── Parse error ── */
225
- .parse-error {
226
- margin-top: 10px;
227
- padding: 10px 14px;
228
- background: var(--red-bg);
229
- border: 1px solid rgba(248, 81, 73, 0.3);
230
- border-radius: var(--radius-sm);
231
- color: var(--red-text);
232
- font-size: 13px;
233
- display: none;
234
- }
235
-
236
- /* ── Results ── */
237
- .results-header {
238
- display: flex;
239
- align-items: center;
240
- justify-content: space-between;
241
- margin-bottom: 16px;
242
- }
243
-
244
- .results-count {
245
- font-size: 13px;
246
- color: var(--text-muted);
247
- }
248
-
249
- .results-count strong {
250
- color: var(--text);
251
- font-weight: 600;
252
- }
253
-
254
- /* ── Receipt card ── */
255
- .receipt-list {
256
- display: flex;
257
- flex-direction: column;
258
- gap: 16px;
259
- }
260
-
261
- .receipt-card {
262
- background: var(--card-bg);
263
- border: 1px solid var(--border);
264
- border-radius: var(--radius);
265
- border-left-width: 4px;
266
- overflow: hidden;
267
- transition: transform 0.1s;
268
- }
269
-
270
- .receipt-card:hover { transform: translateY(-1px); }
271
-
272
- .receipt-card.decision-allowed { border-left-color: var(--green); }
273
- .receipt-card.decision-blocked { border-left-color: var(--red); }
274
- .receipt-card.decision-held_for_approval { border-left-color: var(--yellow); }
275
- .receipt-card.decision-unknown { border-left-color: var(--border); }
276
-
277
- .card-header {
278
- padding: 16px 20px 12px;
279
- display: flex;
280
- align-items: flex-start;
281
- justify-content: space-between;
282
- gap: 12px;
283
- flex-wrap: wrap;
284
- }
285
-
286
- .tool-name {
287
- font-size: 18px;
288
- font-weight: 700;
289
- color: var(--text);
290
- font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
291
- letter-spacing: -0.02em;
292
- word-break: break-all;
293
- }
294
-
295
- .badges {
296
- display: flex;
297
- gap: 6px;
298
- flex-wrap: wrap;
299
- align-items: center;
300
- flex-shrink: 0;
301
- }
302
-
303
- .badge {
304
- display: inline-flex;
305
- align-items: center;
306
- gap: 4px;
307
- padding: 3px 10px;
308
- border-radius: 20px;
309
- font-size: 12px;
310
- font-weight: 600;
311
- letter-spacing: 0.02em;
312
- white-space: nowrap;
313
- }
314
-
315
- .badge-allowed { background: var(--green-bg); color: var(--green-text); border: 1px solid rgba(46, 160, 67, 0.3); }
316
- .badge-blocked { background: var(--red-bg); color: var(--red-text); border: 1px solid rgba(218, 54, 51, 0.3); }
317
- .badge-held_for_approval { background: var(--yellow-bg); color: var(--yellow-text); border: 1px solid rgba(158, 106, 3, 0.3); }
318
- .badge-unknown { background: #1c2128; color: var(--text-muted); border: 1px solid var(--border); }
319
- .badge-dryrun { background: #1c2128; color: var(--yellow-text); border: 1px solid rgba(227, 179, 65, 0.3); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; }
320
-
321
- .card-body {
322
- padding: 0 20px 16px;
323
- }
324
-
325
- .why-box {
326
- padding: 10px 14px;
327
- background: #0d1117;
328
- border: 1px solid var(--border);
329
- border-radius: var(--radius-sm);
330
- margin-bottom: 14px;
331
- font-size: 13px;
332
- color: var(--text-muted);
333
- }
334
-
335
- .why-box .why-label {
336
- font-weight: 600;
337
- color: var(--accent);
338
- margin-right: 6px;
339
- }
340
-
341
- .meta-grid {
342
- display: grid;
343
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
344
- gap: 10px 20px;
345
- margin-bottom: 14px;
346
- }
347
-
348
- .meta-item {}
349
-
350
- .meta-key {
351
- font-size: 11px;
352
- font-weight: 600;
353
- text-transform: uppercase;
354
- letter-spacing: 0.07em;
355
- color: var(--text-subtle);
356
- margin-bottom: 2px;
357
- }
358
-
359
- .meta-value {
360
- font-size: 13px;
361
- color: var(--text-muted);
362
- word-break: break-all;
363
- }
364
-
365
- .meta-value.mono {
366
- font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
367
- font-size: 12px;
368
- }
369
-
370
- /* ── Collapsible raw JSON ── */
371
- .raw-toggle {
372
- width: 100%;
373
- text-align: left;
374
- background: transparent;
375
- border: 1px solid var(--border);
376
- border-radius: var(--radius-sm);
377
- color: var(--text-muted);
378
- font-size: 12px;
379
- font-weight: 500;
380
- padding: 7px 12px;
381
- cursor: pointer;
382
- display: flex;
383
- align-items: center;
384
- gap: 6px;
385
- transition: all 0.15s;
386
- }
387
-
388
- .raw-toggle:hover {
389
- border-color: var(--text-muted);
390
- color: var(--text);
391
- }
392
-
393
- .raw-toggle .arrow {
394
- margin-left: auto;
395
- font-size: 10px;
396
- transition: transform 0.2s;
397
- }
398
-
399
- .raw-toggle.open .arrow { transform: rotate(180deg); }
400
-
401
- .raw-json {
402
- display: none;
403
- margin-top: 8px;
404
- background: #0d1117;
405
- border: 1px solid var(--border);
406
- border-radius: var(--radius-sm);
407
- padding: 12px 14px;
408
- font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
409
- font-size: 12px;
410
- color: var(--text-muted);
411
- overflow-x: auto;
412
- white-space: pre;
413
- line-height: 1.6;
414
- }
415
-
416
- .raw-json.open { display: block; }
417
-
418
- /* ── Empty state ── */
419
- .empty-state {
420
- text-align: center;
421
- padding: 48px 24px;
422
- color: var(--text-muted);
423
- }
424
-
425
- .empty-state .empty-icon { font-size: 40px; margin-bottom: 12px; }
426
- .empty-state p { font-size: 14px; }
427
-
428
- /* ── Footer ── */
429
- footer {
430
- border-top: 1px solid var(--border);
431
- padding: 20px 24px;
432
- text-align: center;
433
- font-size: 13px;
434
- color: var(--text-subtle);
435
- }
436
-
437
- footer a {
438
- color: var(--accent);
439
- text-decoration: none;
440
- }
441
-
442
- footer a:hover { text-decoration: underline; }
443
- </style>
444
- </head>
445
- <body>
446
-
447
- <header>
448
- <div class="header-icon">🛡️</div>
449
- <h1>MCP Guard <span>— Receipt Viewer</span></h1>
450
- <div class="header-spacer"></div>
451
- <span class="header-badge">v0.1</span>
452
- </header>
453
-
454
- <main>
455
- <div class="input-section">
456
- <div class="section-label">Paste or upload receipts</div>
457
- <div class="input-row">
458
- <textarea id="pasteArea"
459
- placeholder='Paste a JSON receipt or JSONL (one receipt per line)&#10;&#10;{"receipt_id": "...", "tool_name": "...", "decision": "allowed", ...}'
460
- ></textarea>
461
- <div class="side-controls">
462
- <div class="drop-zone" id="dropZone">
463
- <input type="file" id="fileInput" accept=".jsonl,.json,.txt" />
464
- <div class="drop-icon">📂</div>
465
- <div class="drop-text">Drop file<br/><em>pp-receipts.jsonl</em></div>
466
- </div>
467
- <button class="btn btn-primary" id="renderBtn">▶ Render</button>
468
- <button class="btn btn-secondary" id="clearBtn">✕ Clear</button>
469
- </div>
470
- </div>
471
- <div class="parse-error" id="parseError"></div>
472
- </div>
473
-
474
- <div id="resultsSection" style="display:none">
475
- <div class="results-header">
476
- <div class="results-count" id="resultsCount"></div>
477
- </div>
478
- <div class="receipt-list" id="receiptList"></div>
479
- </div>
480
-
481
- <div id="emptyState" class="empty-state" style="display:none">
482
- <div class="empty-icon">📭</div>
483
- <p>No receipts to display. Paste JSON above and click Render.</p>
484
- </div>
485
- </main>
486
-
487
- <footer>
488
- <a href="https://github.com/permission-protocol/mcp-guard" target="_blank" rel="noopener">
489
- permission-protocol/mcp-guard
490
- </a>
491
- &nbsp;·&nbsp; MCP Guard Receipt Viewer
492
- </footer>
493
-
494
- <script>
495
- // ── Example receipt ──────────────────────────────────────────────────────
496
- const EXAMPLE = {
497
- "receipt_id": "a3f8c2d1-7b4e-4f9a-b8c3-2d1e5f6a7b8c",
498
- "timestamp": "2026-03-20T15:30:00.000Z",
499
- "agent_id": "claude-desktop",
500
- "tool_name": "delete_user_data",
501
- "decision": "blocked",
502
- "reason": "Matched rule \"block-delete-user-data\"",
503
- "rule_id": "block-delete-user-data",
504
- "request_payload_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
505
- "target_server": "node my-mcp-server.js",
506
- "mode": "enforce"
507
- };
508
-
509
- // ── DOM refs ──────────────────────────────────────────────────────────────
510
- const pasteArea = document.getElementById('pasteArea');
511
- const fileInput = document.getElementById('fileInput');
512
- const dropZone = document.getElementById('dropZone');
513
- const renderBtn = document.getElementById('renderBtn');
514
- const clearBtn = document.getElementById('clearBtn');
515
- const parseError = document.getElementById('parseError');
516
- const resultsSection= document.getElementById('resultsSection');
517
- const resultsCount = document.getElementById('resultsCount');
518
- const receiptList = document.getElementById('receiptList');
519
- const emptyState = document.getElementById('emptyState');
520
-
521
- // ── Parse ─────────────────────────────────────────────────────────────────
522
- function parseInput(raw) {
523
- const text = raw.trim();
524
- if (!text) return { receipts: [], errors: [] };
525
-
526
- const receipts = [];
527
- const errors = [];
528
-
529
- // Try single JSON object first
530
- if (text.startsWith('{')) {
531
- try {
532
- const obj = JSON.parse(text);
533
- if (Array.isArray(obj)) {
534
- obj.forEach((r, i) => receipts.push(r));
535
- } else {
536
- receipts.push(obj);
537
- }
538
- return { receipts, errors };
539
- } catch (e) {
540
- // Fall through to JSONL
541
- }
542
- }
543
-
544
- // Try JSON array
545
- if (text.startsWith('[')) {
546
- try {
547
- const arr = JSON.parse(text);
548
- arr.forEach(r => receipts.push(r));
549
- return { receipts, errors };
550
- } catch (e) {
551
- errors.push('Invalid JSON array: ' + e.message);
552
- return { receipts, errors };
553
- }
554
- }
555
-
556
- // JSONL — one JSON object per line
557
- const lines = text.split('\n');
558
- lines.forEach((line, idx) => {
559
- const l = line.trim();
560
- if (!l) return;
561
- try {
562
- const obj = JSON.parse(l);
563
- receipts.push(obj);
564
- } catch (e) {
565
- errors.push(`Line ${idx + 1}: ${e.message}`);
566
- }
567
- });
568
-
569
- return { receipts, errors };
570
- }
571
-
572
- // ── Decision meta ─────────────────────────────────────────────────────────
573
- function decisionMeta(decision) {
574
- switch ((decision || '').toLowerCase()) {
575
- case 'allowed': return { cls: 'allowed', icon: '🟢', label: 'Allowed' };
576
- case 'blocked': return { cls: 'blocked', icon: '🔴', label: 'Blocked' };
577
- case 'held_for_approval': return { cls: 'held_for_approval', icon: '🟡', label: 'Held for Approval' };
578
- default: return { cls: 'unknown', icon: '⚪', label: decision || 'Unknown' };
579
- }
580
- }
581
-
582
- // ── Format date ───────────────────────────────────────────────────────────
583
- function formatDate(ts) {
584
- if (!ts) return '—';
585
- try {
586
- const d = new Date(ts);
587
- if (isNaN(d)) return ts;
588
- return d.toLocaleString(undefined, {
589
- year: 'numeric', month: 'short', day: 'numeric',
590
- hour: '2-digit', minute: '2-digit', second: '2-digit',
591
- timeZoneName: 'short'
592
- });
593
- } catch { return ts; }
594
- }
595
-
596
- // ── Escape HTML ───────────────────────────────────────────────────────────
597
- function esc(s) {
598
- return String(s ?? '—')
599
- .replace(/&/g, '&amp;')
600
- .replace(/</g, '&lt;')
601
- .replace(/>/g, '&gt;')
602
- .replace(/"/g, '&quot;');
603
- }
604
-
605
- // ── Build card ────────────────────────────────────────────────────────────
606
- function buildCard(receipt, idx) {
607
- const dm = decisionMeta(receipt.decision);
608
- const isDryRun = (receipt.mode || '').toLowerCase() === 'observe';
609
-
610
- // Why explanation
611
- let why;
612
- if (receipt.rule_id) {
613
- why = `Matched rule: <strong>${esc(receipt.rule_id)}</strong>`;
614
- } else {
615
- why = 'No matching rule; default action applied';
616
- }
617
-
618
- // Rule display
619
- const ruleDisplay = receipt.rule_id || 'default action';
620
-
621
- // Hash truncation
622
- const hash = receipt.request_payload_hash || '';
623
- const hashShort = hash.length > 16 ? hash.slice(0, 8) + '…' + hash.slice(-8) : (hash || '—');
624
-
625
- const rawJson = JSON.stringify(receipt, null, 2);
626
-
627
- const card = document.createElement('div');
628
- card.className = `receipt-card decision-${dm.cls}`;
629
- card.innerHTML = `
630
- <div class="card-header">
631
- <div class="tool-name">${esc(receipt.tool_name || 'unknown_tool')}</div>
632
- <div class="badges">
633
- <span class="badge badge-${dm.cls}">${dm.icon} ${dm.label}</span>
634
- ${isDryRun ? '<span class="badge badge-dryrun">🔬 DRY RUN</span>' : ''}
635
- </div>
636
- </div>
637
- <div class="card-body">
638
- <div class="why-box">
639
- <span class="why-label">Why?</span>${why}
640
- </div>
641
- <div class="meta-grid">
642
- <div class="meta-item">
643
- <div class="meta-key">Timestamp</div>
644
- <div class="meta-value">${esc(formatDate(receipt.timestamp))}</div>
645
- </div>
646
- <div class="meta-item">
647
- <div class="meta-key">Agent ID</div>
648
- <div class="meta-value mono">${esc(receipt.agent_id)}</div>
649
- </div>
650
- <div class="meta-item">
651
- <div class="meta-key">Target Server</div>
652
- <div class="meta-value mono">${esc(receipt.target_server)}</div>
653
- </div>
654
- <div class="meta-item">
655
- <div class="meta-key">Mode</div>
656
- <div class="meta-value">${esc(receipt.mode || '—')}</div>
657
- </div>
658
- <div class="meta-item">
659
- <div class="meta-key">Rule Matched</div>
660
- <div class="meta-value mono">${esc(ruleDisplay)}</div>
661
- </div>
662
- <div class="meta-item">
663
- <div class="meta-key">Payload Hash</div>
664
- <div class="meta-value mono" title="${esc(hash)}">${esc(hashShort)}</div>
665
- </div>
666
- ${receipt.receipt_id ? `
667
- <div class="meta-item">
668
- <div class="meta-key">Receipt ID</div>
669
- <div class="meta-value mono">${esc(receipt.receipt_id)}</div>
670
- </div>` : ''}
671
- </div>
672
- <button class="raw-toggle" id="toggle-${idx}" onclick="toggleRaw(${idx})">
673
- <span>{ }</span> Raw JSON
674
- <span class="arrow">▾</span>
675
- </button>
676
- <pre class="raw-json" id="raw-${idx}">${esc(rawJson)}</pre>
677
- </div>
678
- `;
679
- return card;
680
- }
681
-
682
- // ── Toggle raw JSON ───────────────────────────────────────────────────────
683
- function toggleRaw(idx) {
684
- const btn = document.getElementById('toggle-' + idx);
685
- const pre = document.getElementById('raw-' + idx);
686
- btn.classList.toggle('open');
687
- pre.classList.toggle('open');
688
- }
689
-
690
- // ── Render receipts ───────────────────────────────────────────────────────
691
- function render(receipts, errors) {
692
- parseError.style.display = 'none';
693
- receiptList.innerHTML = '';
694
-
695
- if (errors.length) {
696
- parseError.textContent = errors.slice(0, 3).join(' | ') + (errors.length > 3 ? ` (+${errors.length - 3} more)` : '');
697
- parseError.style.display = 'block';
698
- }
699
-
700
- if (receipts.length === 0) {
701
- resultsSection.style.display = 'none';
702
- emptyState.style.display = errors.length ? 'none' : 'block';
703
- return;
704
- }
705
-
706
- emptyState.style.display = 'none';
707
- resultsSection.style.display = 'block';
708
-
709
- const plural = receipts.length === 1 ? 'receipt' : 'receipts';
710
- resultsCount.innerHTML = `Showing <strong>${receipts.length}</strong> ${plural}${errors.length ? ` <span style="color:var(--red-text)">(${errors.length} parse error${errors.length > 1 ? 's' : ''})</span>` : ''}`;
711
-
712
- receipts.forEach((r, i) => {
713
- receiptList.appendChild(buildCard(r, i));
714
- });
715
- }
716
-
717
- // ── Events ────────────────────────────────────────────────────────────────
718
- renderBtn.addEventListener('click', () => {
719
- const { receipts, errors } = parseInput(pasteArea.value);
720
- render(receipts, errors);
721
- });
722
-
723
- pasteArea.addEventListener('keydown', (e) => {
724
- if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') renderBtn.click();
725
- });
726
-
727
- clearBtn.addEventListener('click', () => {
728
- pasteArea.value = '';
729
- parseError.style.display = 'none';
730
- resultsSection.style.display = 'none';
731
- emptyState.style.display = 'none';
732
- receiptList.innerHTML = '';
733
- fileInput.value = '';
734
- });
735
-
736
- // File upload
737
- function handleFile(file) {
738
- if (!file) return;
739
- const reader = new FileReader();
740
- reader.onload = (e) => {
741
- pasteArea.value = e.target.result;
742
- const { receipts, errors } = parseInput(e.target.result);
743
- render(receipts, errors);
744
- };
745
- reader.readAsText(file);
746
- }
747
-
748
- fileInput.addEventListener('change', () => handleFile(fileInput.files[0]));
749
-
750
- // Drag and drop
751
- dropZone.addEventListener('dragover', (e) => {
752
- e.preventDefault();
753
- dropZone.classList.add('drag-over');
754
- });
755
-
756
- dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
757
-
758
- dropZone.addEventListener('drop', (e) => {
759
- e.preventDefault();
760
- dropZone.classList.remove('drag-over');
761
- const file = e.dataTransfer.files[0];
762
- if (file) handleFile(file);
763
- });
764
-
765
- // ── Load example on startup ───────────────────────────────────────────────
766
- (function init() {
767
- render([EXAMPLE], []);
768
- })();
769
- </script>
770
- </body>
771
- </html>