halyn 2.2.1__tar.gz → 2.2.3__tar.gz

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 (64) hide show
  1. {halyn-2.2.1/src/halyn.egg-info → halyn-2.2.3}/PKG-INFO +1 -1
  2. {halyn-2.2.1 → halyn-2.2.3}/pyproject.toml +1 -1
  3. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/__init__.py +1 -1
  4. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/cli.py +15 -0
  5. halyn-2.2.3/src/halyn/dashboard.py +561 -0
  6. halyn-2.2.3/src/halyn/redteam.py +153 -0
  7. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/server.py +9 -0
  8. {halyn-2.2.1 → halyn-2.2.3/src/halyn.egg-info}/PKG-INFO +1 -1
  9. {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/SOURCES.txt +1 -0
  10. halyn-2.2.1/src/halyn/dashboard.py +0 -209
  11. {halyn-2.2.1 → halyn-2.2.3}/LICENSE +0 -0
  12. {halyn-2.2.1 → halyn-2.2.3}/README.md +0 -0
  13. {halyn-2.2.1 → halyn-2.2.3}/setup.cfg +0 -0
  14. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/__main__.py +0 -0
  15. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/__init__.py +0 -0
  16. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/driver.py +0 -0
  17. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/events.py +0 -0
  18. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/identity.py +0 -0
  19. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/_nrp/manifest.py +0 -0
  20. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/audit.py +0 -0
  21. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/auth.py +0 -0
  22. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/autonomy.py +0 -0
  23. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/config.py +0 -0
  24. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/consent.py +0 -0
  25. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/control_plane.py +0 -0
  26. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/discovery.py +0 -0
  27. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/__init__.py +0 -0
  28. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/browser.py +0 -0
  29. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/dds.py +0 -0
  30. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/docker.py +0 -0
  31. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/http_auto.py +0 -0
  32. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/mqtt.py +0 -0
  33. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/opcua.py +0 -0
  34. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/ros2.py +0 -0
  35. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/serial.py +0 -0
  36. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/socket_raw.py +0 -0
  37. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/ssh.py +0 -0
  38. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/unitree.py +0 -0
  39. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/drivers/websocket.py +0 -0
  40. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/engine.py +0 -0
  41. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/integrations/__init__.py +0 -0
  42. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/intent.py +0 -0
  43. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/llm.py +0 -0
  44. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/mcp.py +0 -0
  45. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/mcp_serve.py +0 -0
  46. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/memory/__init__.py +0 -0
  47. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/memory/store.py +0 -0
  48. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/nrp_bridge.py +0 -0
  49. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/py.typed +0 -0
  50. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/sanitizer.py +0 -0
  51. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/__init__.py +0 -0
  52. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/audit_guard.py +0 -0
  53. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/ebpf_monitor.py +0 -0
  54. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/fs_watch.py +0 -0
  55. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/process_guard.py +0 -0
  56. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/security/proxy.py +0 -0
  57. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/shield.py +0 -0
  58. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/types.py +0 -0
  59. {halyn-2.2.1 → halyn-2.2.3}/src/halyn/watchdog.py +0 -0
  60. {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/dependency_links.txt +0 -0
  61. {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/entry_points.txt +0 -0
  62. {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/requires.txt +0 -0
  63. {halyn-2.2.1 → halyn-2.2.3}/src/halyn.egg-info/top_level.txt +0 -0
  64. {halyn-2.2.1 → halyn-2.2.3}/tests/test_halyn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: halyn
3
- Version: 2.2.1
3
+ Version: 2.2.3
4
4
  Summary: Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable.
5
5
  Author-email: Elmadani SALKA <contact@halyn.dev>
6
6
  License-Expression: BUSL-1.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "halyn"
3
- version = "2.2.1"
3
+ version = "2.2.3"
4
4
  description = "Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable."
5
5
  requires-python = ">=3.10"
6
6
  license = "BUSL-1.1"
@@ -9,7 +9,7 @@ Every action intercepted. Every decision auditable.
9
9
  The AI cannot bypass it.
10
10
  """
11
11
 
12
- __version__ = "2.2.1"
12
+ __version__ = "2.2.3"
13
13
  __author__ = "Elmadani SALKA"
14
14
  __license__ = "BUSL-1.1"
15
15
  __email__ = "contact@halyn.dev"
@@ -51,6 +51,13 @@ def main() -> None:
51
51
  # emergency-stop
52
52
  sub.add_parser("emergency-stop", help="STOP ALL NODES IMMEDIATELY")
53
53
 
54
+ # redteam
55
+ p_red = sub.add_parser("redteam", help="Run 24/7 red team audit loop")
56
+ p_red.add_argument("--url", default="http://localhost:7420")
57
+ p_red.add_argument("--interval", type=float, default=30.0)
58
+ p_red.add_argument("--webhook", default="")
59
+ p_red.add_argument("--verbose", action="store_true")
60
+
54
61
  # version
55
62
  sub.add_parser("version", help="Show version")
56
63
 
@@ -66,6 +73,14 @@ def main() -> None:
66
73
  _cmd_test()
67
74
  elif args.command == "emergency-stop":
68
75
  _cmd_emergency_stop(args)
76
+ elif args.command == "redteam":
77
+ from .redteam import run as redteam_run
78
+ redteam_run(
79
+ url=args.url,
80
+ interval=args.interval,
81
+ webhook=args.webhook or None,
82
+ verbose=args.verbose,
83
+ )
69
84
  elif args.command == "version":
70
85
  from . import __version__
71
86
  print(f"Halyn v{__version__}")
@@ -0,0 +1,561 @@
1
+ # Copyright (c) 2026 Elmadani SALKA
2
+ # Licensed under BUSL-1.1. See LICENSE file.
3
+ # Commercial use requires a license — contact@halyn.dev
4
+
5
+ """
6
+ Halyn Dashboard — Built-in web UI served on GET /
7
+
8
+ Uses the real REST API endpoints directly:
9
+ /health /nodes /audit /events/query
10
+ /consent/pending /confirm/pending /intents /execute
11
+ """
12
+
13
+ DASHBOARD_HTML = r"""<!DOCTYPE html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="UTF-8">
17
+ <meta name="viewport" content="width=device-width,initial-scale=1">
18
+ <title>Halyn — AI Governance</title>
19
+ <style>
20
+ *{margin:0;padding:0;box-sizing:border-box}
21
+ :root{
22
+ --bg:#0b0d10;--bg2:#13161b;--bg3:#1a1e27;
23
+ --fg:#e1e4ea;--fg2:#8b919e;--fg3:#5c6370;
24
+ --green:#3ddc84;--green-bg:#0f2318;--green-border:#1a3a28;
25
+ --red:#ef4444;--red-bg:#2a1215;--red-border:#4a1c20;
26
+ --yellow:#f59e0b;--yellow-bg:#1f1a0f;
27
+ --border:#1e2330;
28
+ --font:'Inter',system-ui,sans-serif;
29
+ --mono:'IBM Plex Mono','Courier New',monospace;
30
+ }
31
+ body{background:var(--bg);color:var(--fg);font-family:var(--font);height:100vh;display:flex;flex-direction:column;overflow:hidden}
32
+
33
+ /* TOP BAR */
34
+ .topbar{
35
+ background:var(--bg2);border-bottom:1px solid var(--border);
36
+ padding:.65rem 1.25rem;display:flex;justify-content:space-between;align-items:center;
37
+ flex-shrink:0;
38
+ }
39
+ .topbar-left{display:flex;align-items:center;gap:.75rem}
40
+ .logo{font-size:.95rem;font-weight:700;letter-spacing:-.5px}
41
+ .logo span{color:var(--green)}
42
+ .version{font-size:.7rem;color:var(--fg3);font-family:var(--mono)}
43
+ .topbar-right{display:flex;align-items:center;gap:1rem}
44
+ .pill{
45
+ display:flex;align-items:center;gap:.35rem;
46
+ font-size:.72rem;font-family:var(--mono);
47
+ background:var(--bg3);border:1px solid var(--border);
48
+ padding:.25rem .6rem;border-radius:20px;
49
+ }
50
+ .pill .dot{width:7px;height:7px;border-radius:50%;background:var(--fg3)}
51
+ .pill.online .dot{background:var(--green);box-shadow:0 0 6px var(--green)}
52
+ .pill.stopped .dot{background:var(--red);box-shadow:0 0 6px var(--red)}
53
+ #estop-btn{
54
+ background:var(--red-bg);border:1px solid var(--red-border);
55
+ color:var(--red);font-size:.72rem;font-family:var(--mono);font-weight:600;
56
+ padding:.25rem .75rem;border-radius:4px;cursor:pointer;
57
+ }
58
+ #estop-btn:hover{background:var(--red);color:#fff}
59
+
60
+ /* STAT STRIP */
61
+ .statstrip{
62
+ display:flex;border-bottom:1px solid var(--border);flex-shrink:0;
63
+ background:var(--bg2);
64
+ }
65
+ .stat{
66
+ flex:1;padding:.6rem 1rem;border-right:1px solid var(--border);
67
+ display:flex;flex-direction:column;gap:.15rem;
68
+ }
69
+ .stat:last-child{border-right:none}
70
+ .stat .v{font-family:var(--mono);font-size:1.3rem;font-weight:700;color:var(--green);line-height:1}
71
+ .stat .l{font-size:.65rem;color:var(--fg3);text-transform:uppercase;letter-spacing:.8px}
72
+ .stat.warn .v{color:var(--yellow)}
73
+ .stat.danger .v{color:var(--red)}
74
+
75
+ /* MAIN GRID */
76
+ .main{display:grid;grid-template-columns:1fr 1fr;flex:1;overflow:hidden}
77
+ .col{display:flex;flex-direction:column;border-right:1px solid var(--border);overflow:hidden}
78
+ .col:last-child{border-right:none}
79
+
80
+ /* PANEL */
81
+ .panel{flex:1;display:flex;flex-direction:column;overflow:hidden;border-bottom:1px solid var(--border)}
82
+ .panel:last-child{border-bottom:none}
83
+ .panel-head{
84
+ padding:.5rem .9rem;border-bottom:1px solid var(--border);flex-shrink:0;
85
+ display:flex;justify-content:space-between;align-items:center;
86
+ background:var(--bg2);
87
+ }
88
+ .panel-head h2{font-size:.72rem;font-weight:600;color:var(--fg2);text-transform:uppercase;letter-spacing:1px}
89
+ .panel-head .badge{
90
+ font-size:.62rem;font-family:var(--mono);
91
+ background:var(--bg3);color:var(--fg3);
92
+ padding:2px 7px;border-radius:10px;border:1px solid var(--border);
93
+ }
94
+ .panel-body{flex:1;overflow-y:auto;padding:.6rem .9rem}
95
+
96
+ /* AUDIT TABLE */
97
+ .audit-row{
98
+ display:grid;grid-template-columns:55px 1fr 55px 90px;
99
+ gap:.5rem;padding:.3rem 0;border-bottom:1px solid var(--border);
100
+ font-size:.7rem;font-family:var(--mono);align-items:center;
101
+ }
102
+ .audit-row:last-child{border-bottom:none}
103
+ .audit-row .t{color:var(--fg3)}
104
+ .audit-row .tool{color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
105
+ .audit-row .hash{color:var(--fg3);font-size:.62rem;text-align:right;overflow:hidden;text-overflow:ellipsis}
106
+ .ok-badge{background:var(--green-bg);color:var(--green);border:1px solid var(--green-border);padding:1px 6px;border-radius:3px;font-size:.6rem;font-weight:700}
107
+ .fail-badge{background:var(--red-bg);color:var(--red);border:1px solid var(--red-border);padding:1px 6px;border-radius:3px;font-size:.6rem;font-weight:700}
108
+
109
+ /* EVENTS */
110
+ .event-row{
111
+ padding:.3rem 0;border-bottom:1px solid var(--border);
112
+ font-size:.7rem;font-family:var(--mono);display:flex;gap:.6rem;align-items:baseline;
113
+ }
114
+ .event-row:last-child{border-bottom:none}
115
+ .event-row .sev{font-size:.6rem;font-weight:700;padding:1px 5px;border-radius:3px;flex-shrink:0}
116
+ .sev.info{background:#0f1f2e;color:#60a5fa;border:1px solid #1a3a5c}
117
+ .sev.warning{background:var(--yellow-bg);color:var(--yellow);border:1px solid #3a2a0f}
118
+ .sev.critical,.sev.emergency{background:var(--red-bg);color:var(--red);border:1px solid var(--red-border)}
119
+ .event-row .name{color:var(--fg);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
120
+ .event-row .src{color:var(--fg3);font-size:.62rem}
121
+
122
+ /* NODES */
123
+ .node-card{
124
+ background:var(--bg2);border:1px solid var(--border);border-radius:6px;
125
+ padding:.6rem .8rem;margin-bottom:.4rem;font-size:.73rem;
126
+ }
127
+ .node-card .nid{font-family:var(--mono);color:var(--green);font-size:.7rem}
128
+ .node-card .nmeta{color:var(--fg3);margin-top:.2rem;font-size:.65rem}
129
+ .empty{color:var(--fg3);font-size:.78rem;font-style:italic;padding:.5rem 0}
130
+
131
+ /* CONSENT */
132
+ .consent-card{
133
+ background:var(--bg2);border:1px solid var(--yellow-bg);border-radius:6px;
134
+ padding:.6rem .8rem;margin-bottom:.4rem;font-size:.73rem;
135
+ }
136
+ .consent-card .nid{font-family:var(--mono);color:var(--yellow)}
137
+ .consent-actions{display:flex;gap:.4rem;margin-top:.5rem}
138
+ .btn-approve{background:var(--green-bg);border:1px solid var(--green-border);color:var(--green);font-size:.68rem;padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--mono)}
139
+ .btn-deny{background:var(--red-bg);border:1px solid var(--red-border);color:var(--red);font-size:.68rem;padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--mono)}
140
+ .btn-approve:hover{background:var(--green);color:#000}
141
+ .btn-deny:hover{background:var(--red);color:#fff}
142
+
143
+ /* COMMAND */
144
+ .cmd-panel{flex:1;display:flex;flex-direction:column;overflow:hidden}
145
+ .cmd-history{flex:1;overflow-y:auto;padding:.6rem .9rem;font-family:var(--mono);font-size:.72rem}
146
+ .cmd-line{padding:.25rem 0;border-bottom:1px solid var(--border);display:flex;gap:.6rem;line-height:1.5}
147
+ .cmd-line:last-child{border-bottom:none}
148
+ .cmd-line .who{color:var(--fg3);flex-shrink:0}
149
+ .cmd-line .txt{flex:1;word-break:break-all}
150
+ .cmd-line.user .who{color:var(--green)}
151
+ .cmd-line.user .txt{color:var(--fg)}
152
+ .cmd-line.res.ok .txt{color:var(--green)}
153
+ .cmd-line.res.err .txt{color:var(--red)}
154
+ .cmd-line.res.info .txt{color:var(--fg2)}
155
+ .cmd-input-row{
156
+ display:flex;gap:.5rem;padding:.6rem .9rem;
157
+ border-top:1px solid var(--border);flex-shrink:0;background:var(--bg2);
158
+ }
159
+ .cmd-input{
160
+ flex:1;background:var(--bg3);border:1px solid var(--border);
161
+ color:var(--fg);padding:.45rem .7rem;border-radius:5px;
162
+ font-size:.78rem;font-family:var(--mono);outline:none;
163
+ }
164
+ .cmd-input:focus{border-color:var(--green)}
165
+ .cmd-send{
166
+ background:var(--green-bg);border:1px solid var(--green-border);
167
+ color:var(--green);font-size:.75rem;font-family:var(--mono);font-weight:600;
168
+ padding:.45rem .9rem;border-radius:5px;cursor:pointer;
169
+ }
170
+ .cmd-send:hover{background:var(--green);color:#000}
171
+
172
+ /* AUDIT VERIFY INDICATOR */
173
+ .chain-status{
174
+ font-size:.65rem;font-family:var(--mono);
175
+ padding:2px 7px;border-radius:3px;
176
+ }
177
+ .chain-status.valid{background:var(--green-bg);color:var(--green);border:1px solid var(--green-border)}
178
+ .chain-status.broken{background:var(--red-bg);color:var(--red);border:1px solid var(--red-border)}
179
+
180
+ /* SCROLLBAR */
181
+ ::-webkit-scrollbar{width:4px}
182
+ ::-webkit-scrollbar-track{background:transparent}
183
+ ::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
184
+ </style>
185
+ </head>
186
+ <body>
187
+
188
+ <div class="topbar">
189
+ <div class="topbar-left">
190
+ <div class="logo">⬡ <span>Halyn</span></div>
191
+ <div class="version" id="version">v—</div>
192
+ </div>
193
+ <div class="topbar-right">
194
+ <div class="pill" id="status-pill">
195
+ <div class="dot"></div>
196
+ <span id="status-txt">connecting</span>
197
+ </div>
198
+ <button id="estop-btn" onclick="emergencyStop()">⚠ STOP</button>
199
+ </div>
200
+ </div>
201
+
202
+ <div class="statstrip">
203
+ <div class="stat" id="st-nodes"><div class="v" id="sv-nodes">—</div><div class="l">Nodes</div></div>
204
+ <div class="stat" id="st-audit"><div class="v" id="sv-audit">—</div><div class="l">Audit</div></div>
205
+ <div class="stat" id="st-events"><div class="v" id="sv-events">—</div><div class="l">Events</div></div>
206
+ <div class="stat" id="st-consent"><div class="v" id="sv-consent">—</div><div class="l">Pending</div></div>
207
+ <div class="stat" id="st-watchdog"><div class="v" id="sv-watchdog">—</div><div class="l">Watchdog</div></div>
208
+ <div class="stat" id="st-chain"><div class="v" id="sv-chain">—</div><div class="l">Chain</div></div>
209
+ </div>
210
+
211
+ <div class="main">
212
+ <!-- LEFT COL -->
213
+ <div class="col">
214
+
215
+ <!-- NODES (top-left) -->
216
+ <div class="panel" style="max-height:35%">
217
+ <div class="panel-head">
218
+ <h2>Nodes</h2>
219
+ <span class="badge" id="badge-nodes">0</span>
220
+ </div>
221
+ <div class="panel-body" id="panel-nodes">
222
+ <div class="empty">No nodes connected. Run <code>halyn scan</code> to discover.</div>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- CONSENT (bottom-left) -->
227
+ <div class="panel" style="max-height:30%">
228
+ <div class="panel-head">
229
+ <h2>Consent Pending</h2>
230
+ <span class="badge" id="badge-consent">0</span>
231
+ </div>
232
+ <div class="panel-body" id="panel-consent">
233
+ <div class="empty">No pending consent requests.</div>
234
+ </div>
235
+ </div>
236
+
237
+ <!-- COMMAND (bottom-left) -->
238
+ <div class="panel cmd-panel">
239
+ <div class="panel-head">
240
+ <h2>Execute</h2>
241
+ <span class="badge">tool · node · args</span>
242
+ </div>
243
+ <div class="cmd-history" id="cmd-history">
244
+ <div class="cmd-line res info"><span class="who">sys</span><span class="txt">Halyn console — type help for commands.</span></div>
245
+ </div>
246
+ <div class="cmd-input-row">
247
+ <input class="cmd-input" id="cmd-input" placeholder='{"tool":"my_tool","node":"nrp://scope/kind/name","args":{}}' />
248
+ <button class="cmd-send" onclick="cmdSend()">RUN</button>
249
+ </div>
250
+ </div>
251
+
252
+ </div>
253
+
254
+ <!-- RIGHT COL -->
255
+ <div class="col">
256
+
257
+ <!-- AUDIT (top-right) -->
258
+ <div class="panel" style="flex:1.2">
259
+ <div class="panel-head">
260
+ <h2>Audit Chain</h2>
261
+ <div style="display:flex;gap:.5rem;align-items:center">
262
+ <span class="chain-status valid" id="chain-badge">VALID</span>
263
+ <span class="badge" id="badge-audit">0</span>
264
+ </div>
265
+ </div>
266
+ <div class="panel-body" id="panel-audit">
267
+ <div class="empty">No audit entries yet.</div>
268
+ </div>
269
+ </div>
270
+
271
+ <!-- EVENTS (bottom-right) -->
272
+ <div class="panel" style="flex:1">
273
+ <div class="panel-head">
274
+ <h2>Event Stream</h2>
275
+ <span class="badge" id="badge-events">0</span>
276
+ </div>
277
+ <div class="panel-body" id="panel-events">
278
+ <div class="empty">Listening for events via SSE...</div>
279
+ </div>
280
+ </div>
281
+
282
+ </div>
283
+ </div>
284
+
285
+ <script>
286
+ const $ = id => document.getElementById(id);
287
+
288
+ // ── Helpers ──────────────────────────────────────────────
289
+ function fmtTime(ts) {
290
+ if (!ts) return '—';
291
+ const d = new Date(typeof ts === 'number' ? ts * 1000 : ts);
292
+ return d.toTimeString().slice(0, 8);
293
+ }
294
+
295
+ function addCmdLine(cls, who, txt) {
296
+ const h = $('cmd-history');
297
+ const d = document.createElement('div');
298
+ d.className = 'cmd-line ' + cls;
299
+ d.innerHTML = '<span class="who">' + who + '</span><span class="txt">' + txt + '</span>';
300
+ h.appendChild(d);
301
+ h.scrollTop = h.scrollHeight;
302
+ }
303
+
304
+ // ── Health / Refresh ──────────────────────────────────────
305
+ async function refresh() {
306
+ try {
307
+ const h = await fetch('/health').then(r => r.json());
308
+
309
+ // Status pill
310
+ const pill = $('status-pill');
311
+ if (h.emergency_stop) {
312
+ pill.className = 'pill stopped';
313
+ $('status-txt').textContent = 'EMERGENCY STOP';
314
+ } else {
315
+ pill.className = 'pill online';
316
+ $('status-txt').textContent = 'running';
317
+ }
318
+
319
+ // Version (from cli — not in health, use static)
320
+ $('version').textContent = 'v2.2.2';
321
+
322
+ // Stats strip
323
+ $('sv-nodes').textContent = h.nodes;
324
+ $('sv-audit').textContent = h.audit_entries;
325
+ $('sv-events').textContent = h.event_bus.total;
326
+ $('sv-consent').textContent = h.pending_consents + h.pending_confirmations;
327
+ $('sv-watchdog').textContent = h.watchdog.overall;
328
+ $('sv-chain').textContent = h.audit_chain_valid ? 'OK' : 'BROKEN';
329
+
330
+ const stChain = $('st-chain');
331
+ stChain.className = 'stat ' + (h.audit_chain_valid ? '' : 'danger');
332
+ const stConsent = $('st-consent');
333
+ stConsent.className = 'stat ' + (h.pending_consents + h.pending_confirmations > 0 ? 'warn' : '');
334
+ const stWatch = $('st-watchdog');
335
+ stWatch.className = 'stat ' + (h.watchdog.overall === 'green' ? '' : h.watchdog.overall === 'yellow' ? 'warn' : 'danger');
336
+
337
+ // Chain badge
338
+ const cb = $('chain-badge');
339
+ if (h.audit_chain_valid) { cb.className = 'chain-status valid'; cb.textContent = 'VALID'; }
340
+ else { cb.className = 'chain-status broken'; cb.textContent = 'BROKEN'; }
341
+
342
+ } catch(e) {
343
+ $('status-pill').className = 'pill';
344
+ $('status-txt').textContent = 'offline';
345
+ }
346
+
347
+ refreshNodes();
348
+ refreshAudit();
349
+ refreshConsent();
350
+ refreshEvents();
351
+ }
352
+
353
+ // ── Nodes ─────────────────────────────────────────────────
354
+ async function refreshNodes() {
355
+ try {
356
+ const d = await fetch('/nodes').then(r => r.json());
357
+ $('badge-nodes').textContent = d.count;
358
+ $('sv-nodes').textContent = d.count;
359
+ const panel = $('panel-nodes');
360
+ if (d.count === 0) {
361
+ panel.innerHTML = '<div class="empty">No nodes connected.</div>';
362
+ return;
363
+ }
364
+ panel.innerHTML = Object.entries(d.nodes).map(([id, m]) => {
365
+ const obs = (m.observe || []).map(c => c.name).join(', ') || '—';
366
+ const acts = (m.act || []).map(a => a.name).join(', ') || '—';
367
+ return '<div class="node-card">' +
368
+ '<div class="nid">' + (m.nrp_id || id) + '</div>' +
369
+ '<div class="nmeta">obs: ' + obs + '</div>' +
370
+ '<div class="nmeta">act: ' + acts + '</div>' +
371
+ '</div>';
372
+ }).join('');
373
+ } catch(e) {}
374
+ }
375
+
376
+ // ── Audit ─────────────────────────────────────────────────
377
+ async function refreshAudit() {
378
+ try {
379
+ const d = await fetch('/audit?limit=30').then(r => r.json());
380
+ $('badge-audit').textContent = d.count;
381
+ $('sv-audit').textContent = d.count;
382
+ const panel = $('panel-audit');
383
+ if (!d.entries || d.entries.length === 0) {
384
+ panel.innerHTML = '<div class="empty">No audit entries yet.</div>';
385
+ return;
386
+ }
387
+ panel.innerHTML = [...d.entries].reverse().map(e => {
388
+ const badge = e.result_ok
389
+ ? '<span class="ok-badge">OK</span>'
390
+ : '<span class="fail-badge">FAIL</span>';
391
+ const hash = e.hash ? e.hash.slice(0, 10) + '…' : '—';
392
+ return '<div class="audit-row">' +
393
+ '<span class="t">' + fmtTime(e.timestamp) + '</span>' +
394
+ '<span class="tool">' + (e.tool || '—') + '</span>' +
395
+ badge +
396
+ '<span class="hash">' + hash + '</span>' +
397
+ '</div>';
398
+ }).join('');
399
+ } catch(e) {}
400
+ }
401
+
402
+ // ── Events ────────────────────────────────────────────────
403
+ async function refreshEvents() {
404
+ try {
405
+ const d = await fetch('/events/query?n=20').then(r => r.json());
406
+ $('badge-events').textContent = d.total;
407
+ $('sv-events').textContent = d.total;
408
+ const panel = $('panel-events');
409
+ if (!d.events || d.events.length === 0) {
410
+ panel.innerHTML = '<div class="empty">No events yet.</div>';
411
+ return;
412
+ }
413
+ panel.innerHTML = [...d.events].reverse().map(e => {
414
+ const sev = e.severity || 'info';
415
+ return '<div class="event-row">' +
416
+ '<span class="sev ' + sev + '">' + sev.toUpperCase() + '</span>' +
417
+ '<span class="name">' + (e.name || '—') + '</span>' +
418
+ '<span class="src">' + (e.source || '') + '</span>' +
419
+ '</div>';
420
+ }).join('');
421
+ } catch(e) {}
422
+ }
423
+
424
+ // ── Consent ───────────────────────────────────────────────
425
+ async function refreshConsent() {
426
+ try {
427
+ const d = await fetch('/consent/pending').then(r => r.json());
428
+ const count = d.count || 0;
429
+ $('badge-consent').textContent = count;
430
+ $('sv-consent').textContent = count;
431
+ const panel = $('panel-consent');
432
+ if (count === 0) {
433
+ panel.innerHTML = '<div class="empty">No pending consent requests.</div>';
434
+ return;
435
+ }
436
+ panel.innerHTML = d.pending.map(r => {
437
+ const id = r.nrp_id || r.id || '?';
438
+ return '<div class="consent-card">' +
439
+ '<div class="nid">' + id + '</div>' +
440
+ '<div class="consent-actions">' +
441
+ '<button class="btn-approve" onclick="consentApprove(' + JSON.stringify(id) + ')">APPROVE</button>' +
442
+ '<button class="btn-deny" onclick="consentDeny(' + JSON.stringify(id) + ')">DENY</button>' +
443
+ '</div></div>';
444
+ }).join('');
445
+ } catch(e) {}
446
+ }
447
+
448
+ async function consentApprove(nrp_id) {
449
+ await fetch('/consent/approve', {
450
+ method:'POST', headers:{'Content-Type':'application/json'},
451
+ body: JSON.stringify({nrp_id, level:'full', user_id:'dashboard'})
452
+ });
453
+ refreshConsent();
454
+ }
455
+
456
+ async function consentDeny(nrp_id) {
457
+ await fetch('/consent/deny', {
458
+ method:'POST', headers:{'Content-Type':'application/json'},
459
+ body: JSON.stringify({nrp_id, reason:'denied via dashboard'})
460
+ });
461
+ refreshConsent();
462
+ }
463
+
464
+ // ── Execute (command) ──────────────────────────────────────
465
+ async function cmdSend() {
466
+ const inp = $('cmd-input');
467
+ const raw = inp.value.trim();
468
+ if (!raw) return;
469
+ inp.value = '';
470
+
471
+ if (raw === 'help') {
472
+ addCmdLine('res info', 'sys',
473
+ 'Examples:<br>' +
474
+ '{"tool":"ping","node":"nrp://local/server/main","args":{}}<br>' +
475
+ '{"tool":"restart","node":"nrp://prod/service/nginx","args":{}}'
476
+ );
477
+ return;
478
+ }
479
+
480
+ addCmdLine('user', 'you', raw);
481
+
482
+ let body;
483
+ try { body = JSON.parse(raw); }
484
+ catch(e) {
485
+ addCmdLine('res err', 'err', 'Invalid JSON — ' + e.message);
486
+ return;
487
+ }
488
+
489
+ try {
490
+ const r = await fetch('/execute', {
491
+ method: 'POST',
492
+ headers: {'Content-Type': 'application/json'},
493
+ body: JSON.stringify({
494
+ tool: body.tool || '',
495
+ args: body.args || {},
496
+ user_id: 'dashboard',
497
+ intent: body.intent || '',
498
+ })
499
+ });
500
+ const d = await r.json();
501
+ if (d.ok) {
502
+ addCmdLine('res ok', 'ok', JSON.stringify(d.data));
503
+ } else {
504
+ addCmdLine('res err', 'err', d.error || d.status || 'failed');
505
+ }
506
+ } catch(e) {
507
+ addCmdLine('res err', 'err', e.message);
508
+ }
509
+
510
+ refresh();
511
+ }
512
+
513
+ // ── Emergency Stop ─────────────────────────────────────────
514
+ async function emergencyStop() {
515
+ if (!confirm('Send EMERGENCY STOP to all nodes?')) return;
516
+ try {
517
+ await fetch('/emergency-stop', {method:'POST'});
518
+ addCmdLine('res err', 'sys', '⚠ EMERGENCY STOP SENT');
519
+ refresh();
520
+ } catch(e) {}
521
+ }
522
+
523
+ // ── SSE live events ────────────────────────────────────────
524
+ function connectSSE() {
525
+ const es = new EventSource('/events');
526
+ es.onmessage = function(e) {
527
+ try {
528
+ const ev = JSON.parse(e.data);
529
+ const panel = $('panel-events');
530
+ // Remove empty placeholder
531
+ const empty = panel.querySelector('.empty');
532
+ if (empty) empty.remove();
533
+ const row = document.createElement('div');
534
+ row.className = 'event-row';
535
+ const sev = ev.severity || 'info';
536
+ row.innerHTML =
537
+ '<span class="sev ' + sev + '">' + sev.toUpperCase() + '</span>' +
538
+ '<span class="name">' + (ev.name || '—') + '</span>' +
539
+ '<span class="src">' + (ev.source || '') + '</span>';
540
+ panel.insertBefore(row, panel.firstChild);
541
+ // Keep last 30
542
+ while (panel.children.length > 30) panel.removeChild(panel.lastChild);
543
+ // Update badge
544
+ const cur = parseInt($('badge-events').textContent) || 0;
545
+ $('badge-events').textContent = cur + 1;
546
+ $('sv-events').textContent = cur + 1;
547
+ } catch(e) {}
548
+ };
549
+ es.onerror = function() {
550
+ setTimeout(connectSSE, 3000);
551
+ };
552
+ }
553
+
554
+ // ── Init ───────────────────────────────────────────────────
555
+ $('cmd-input').addEventListener('keydown', e => { if (e.key === 'Enter') cmdSend(); });
556
+ refresh();
557
+ setInterval(refresh, 5000);
558
+ connectSSE();
559
+ </script>
560
+ </body>
561
+ </html>"""
@@ -0,0 +1,153 @@
1
+ # Copyright (c) 2026 Elmadani SALKA
2
+ # Licensed under BUSL-1.1. See LICENSE file.
3
+ # Commercial use requires a license - contact@halyn.dev
4
+
5
+ from __future__ import annotations
6
+ import asyncio, logging, signal, sys, time
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ log = logging.getLogger("halyn.redteam")
11
+
12
+ PROBES = [
13
+ {"name": "probe.noop", "tool": "halyn.probe.noop",
14
+ "args": {"echo": "ping"}, "expect": "any", "intent": "redteam.noop"},
15
+ {"name": "probe.delete", "tool": "halyn.probe.delete_all",
16
+ "args": {"target": "*"}, "expect": "blocked", "intent": "redteam.dangerous"},
17
+ {"name": "probe.exfil", "tool": "halyn.probe.exfil",
18
+ "args": {"destination": "http://evil.example.com"}, "expect": "blocked", "intent": "redteam.exfil"},
19
+ {"name": "probe.fs_write", "tool": "halyn.probe.write_file",
20
+ "args": {"path": "/etc/passwd", "content": "pwned"}, "expect": "blocked", "intent": "redteam.fs"},
21
+ ]
22
+
23
+ @dataclass
24
+ class ProbeResult:
25
+ name: str; ok: bool; expected: bool; status: str; duration_ms: float; error: str = ""
26
+
27
+ @dataclass
28
+ class LoopStats:
29
+ cycles: int = 0; probes_sent: int = 0; probes_expected: int = 0
30
+ chain_checks: int = 0; chain_breaks: int = 0; alerts_sent: int = 0
31
+ started_at: float = field(default_factory=time.time)
32
+ def uptime_seconds(self): return time.time() - self.started_at
33
+
34
+ async def _get(session, url):
35
+ import aiohttp
36
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as r:
37
+ return await r.json()
38
+
39
+ async def _post(session, url, body):
40
+ import aiohttp
41
+ async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as r:
42
+ return await r.json()
43
+
44
+ async def run_probe(session, base_url, probe):
45
+ t0 = time.perf_counter()
46
+ try:
47
+ resp = await _post(session, f"{base_url}/execute", {
48
+ "tool": probe["tool"], "args": probe["args"],
49
+ "user_id": "halyn.redteam", "intent": probe["intent"],
50
+ })
51
+ ms = (time.perf_counter() - t0) * 1000
52
+ result_ok = resp.get("ok", False)
53
+ status = resp.get("status", "unknown")
54
+ expected = (not result_ok) if probe["expect"] == "blocked" else True
55
+ return ProbeResult(probe["name"], True, expected, status, ms)
56
+ except asyncio.TimeoutError:
57
+ return ProbeResult(probe["name"], False, False, "timeout", (time.perf_counter()-t0)*1000, "timeout")
58
+ except Exception as e:
59
+ return ProbeResult(probe["name"], False, False, "error", (time.perf_counter()-t0)*1000, str(e)[:200])
60
+
61
+ async def check_chain(session, base_url):
62
+ try:
63
+ r = await _get(session, f"{base_url}/audit/verify")
64
+ return r.get("valid", False), r.get("entries_checked", 0), r.get("message", "")
65
+ except Exception as e:
66
+ return False, 0, f"unreachable: {e}"
67
+
68
+ async def send_alert(session, webhook, msg, stats):
69
+ log.critical("ALERT: %s", msg)
70
+ stats.alerts_sent += 1
71
+ if not webhook:
72
+ return
73
+ try:
74
+ import aiohttp
75
+ payload = {"text": f":rotating_light: *Halyn Alert*
76
+ {msg}
77
+ Cycle {stats.cycles} | Uptime {stats.uptime_seconds():.0f}s | Chain breaks: {stats.chain_breaks}"}
78
+ async with session.post(webhook, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as r:
79
+ log.info("alert.webhook status=%d", r.status)
80
+ except Exception as e:
81
+ log.error("alert.webhook failed: %s", e)
82
+
83
+ async def redteam_loop(base_url, interval, webhook, verbose):
84
+ import aiohttp
85
+ stats = LoopStats()
86
+ prev_tip = None
87
+ print(f"
88
+ Halyn Red Team")
89
+ print(f" Target: {base_url}")
90
+ print(f" Interval: {interval}s | Probes: {len(PROBES)} | Webhook: {"yes" if webhook else "no"}")
91
+ print(f" Ctrl+C to stop
92
+ ")
93
+ print(f" CYC TIME PROBES CHAIN MS")
94
+ print(f" " + "-"*60)
95
+ async with aiohttp.ClientSession() as session:
96
+ while True:
97
+ t0 = time.time()
98
+ stats.cycles += 1
99
+ try:
100
+ h = await _get(session, f"{base_url}/health")
101
+ except Exception as e:
102
+ await send_alert(session, webhook, f"Halyn unreachable: {e}", stats)
103
+ await asyncio.sleep(interval)
104
+ continue
105
+ if not h.get("running", False):
106
+ await send_alert(session, webhook, "running=false", stats)
107
+ results = []
108
+ for probe in PROBES:
109
+ r = await run_probe(session, base_url, probe)
110
+ results.append(r)
111
+ stats.probes_sent += 1
112
+ if r.expected:
113
+ stats.probes_expected += 1
114
+ else:
115
+ await send_alert(session, webhook,
116
+ f"Probe {r.name!r} unexpected: status={r.status} expected={probe["expect"]} error={r.error}", stats)
117
+ if verbose:
118
+ log.info(" %s %s -> %s (%.0fms)", "OK" if r.expected else "FAIL", r.name, r.status, r.duration_ms)
119
+ valid, count, msg = await check_chain(session, base_url)
120
+ stats.chain_checks += 1
121
+ if not valid:
122
+ stats.chain_breaks += 1
123
+ await send_alert(session, webhook, f"CHAIN BROKEN cycle={stats.cycles}: {msg} entries={count}", stats)
124
+ try:
125
+ ar = await _get(session, f"{base_url}/audit?limit=1")
126
+ tip = ar.get("chain_tip", "")
127
+ if prev_tip and tip == "GENESIS" and stats.cycles > 1:
128
+ await send_alert(session, webhook, f"Chain tip reset to GENESIS at cycle {stats.cycles} - log may have been wiped", stats)
129
+ prev_tip = tip
130
+ except Exception:
131
+ pass
132
+ n_ok = sum(1 for r in results if r.expected)
133
+ chain_str = f"OK ({count} entries)" if valid else f"BROKEN ({count} entries)"
134
+ ms = (time.time() - t0) * 1000
135
+ print(f" {stats.cycles:>4} {time.strftime("%H:%M:%S")} {n_ok}/{len(results)} probes {chain_str:<26} {ms:>5.0f}ms")
136
+ await asyncio.sleep(max(0.0, interval - (time.time() - t0)))
137
+
138
+ def run(url="http://localhost:7420", interval=30.0, webhook=None, verbose=False):
139
+ try:
140
+ import aiohttp
141
+ except ImportError:
142
+ print("Error: aiohttp required -- pip install halyn"); sys.exit(1)
143
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
144
+ loop = asyncio.new_event_loop()
145
+ def _stop(sig, frame):
146
+ print("
147
+ Stopping..."); loop.stop()
148
+ signal.signal(signal.SIGINT, _stop)
149
+ signal.signal(signal.SIGTERM, _stop)
150
+ try:
151
+ loop.run_until_complete(redteam_loop(url, interval, webhook, verbose))
152
+ finally:
153
+ loop.close()
@@ -54,6 +54,7 @@ def create_app(control_plane: Any, api_key: str = "") -> "web.Application":
54
54
  if not HAS_AIOHTTP:
55
55
  raise ImportError("aiohttp required: pip install aiohttp")
56
56
 
57
+ from .dashboard import DASHBOARD_HTML
57
58
  app = web.Application()
58
59
  cp = control_plane
59
60
 
@@ -72,6 +73,13 @@ def create_app(control_plane: Any, api_key: str = "") -> "web.Application":
72
73
 
73
74
  # ─── Health ─────────────────────────────────
74
75
 
76
+ # --- Dashboard ---
77
+
78
+ async def handle_dashboard(req: web.Request) -> web.Response:
79
+ from . import __version__
80
+ html = DASHBOARD_HTML.replace("v2.2.2", f"v{__version__}")
81
+ return web.Response(text=html, content_type="text/html")
82
+
75
83
  async def handle_health(req: web.Request) -> web.Response:
76
84
  return _json(cp.status())
77
85
 
@@ -272,6 +280,7 @@ def create_app(control_plane: Any, api_key: str = "") -> "web.Application":
272
280
 
273
281
  # ─── Register routes ────────────────────────
274
282
 
283
+ app.router.add_get("/", handle_dashboard)
275
284
  app.router.add_get("/health", handle_health)
276
285
  app.router.add_get("/nodes", handle_nodes)
277
286
  app.router.add_post("/execute", handle_execute)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: halyn
3
- Version: 2.2.1
3
+ Version: 2.2.3
4
4
  Summary: Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable.
5
5
  Author-email: Elmadani SALKA <contact@halyn.dev>
6
6
  License-Expression: BUSL-1.1
@@ -19,6 +19,7 @@ src/halyn/mcp.py
19
19
  src/halyn/mcp_serve.py
20
20
  src/halyn/nrp_bridge.py
21
21
  src/halyn/py.typed
22
+ src/halyn/redteam.py
22
23
  src/halyn/sanitizer.py
23
24
  src/halyn/server.py
24
25
  src/halyn/shield.py
@@ -1,209 +0,0 @@
1
- # Copyright (c) 2026 Elmadani SALKA
2
- # Licensed under BUSL-1.1. See LICENSE file.
3
- # Commercial use requires a license — contact@halyn.dev
4
-
5
- """
6
- Halyn Dashboard — Built-in web UI for the MCP Server.
7
-
8
- When running halyn-mcp, opening http://localhost:7420 shows this dashboard.
9
- Real-time device status, shield rules, audit chain — all visual.
10
- """
11
-
12
- DASHBOARD_HTML = '''<!DOCTYPE html>
13
- <html lang="en">
14
- <head>
15
- <meta charset="UTF-8">
16
- <meta name="viewport" content="width=device-width,initial-scale=1">
17
- <title>Halyn Dashboard</title>
18
- <style>
19
- *{margin:0;padding:0;box-sizing:border-box}
20
- :root{--bg:#0b0d10;--bg2:#13161b;--fg:#e1e4ea;--fg2:#8b919e;--fg3:#5c6370;--accent:#0a6e3f;--accent2:#3ddc84;--red:#ef4444;--border:#1e2330;--font:system-ui,sans-serif;--mono:'Courier New',monospace}
21
- body{background:var(--bg);color:var(--fg);font-family:var(--font);min-height:100vh}
22
- .top{background:var(--bg2);border-bottom:1px solid var(--border);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center}
23
- .top h1{font-size:1rem;font-weight:600;display:flex;align-items:center;gap:.5rem}
24
- .top h1 span{color:var(--accent2)}
25
- .top .status{font-size:.75rem;color:var(--accent2);font-family:var(--mono);display:flex;align-items:center;gap:.4rem}
26
- .top .dot{width:8px;height:8px;border-radius:50%;background:var(--accent2);animation:pulse 2s infinite}
27
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
28
- .grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--border);margin:0;height:calc(100vh - 49px)}
29
- .panel{background:var(--bg);padding:1rem;overflow-y:auto}
30
- .panel h2{font-size:.8rem;font-weight:600;color:var(--fg2);text-transform:uppercase;letter-spacing:1px;margin-bottom:.75rem;display:flex;align-items:center;gap:.5rem}
31
- .panel h2 .count{background:var(--bg2);color:var(--fg3);font-size:.65rem;padding:2px 6px;border-radius:8px}
32
-
33
- /* Chat / Command */
34
- .chat{display:flex;flex-direction:column;height:100%}
35
- .messages{flex:1;overflow-y:auto;padding-bottom:.5rem}
36
- .msg{margin-bottom:.5rem;padding:.5rem .75rem;border-radius:8px;font-size:.82rem;line-height:1.5;max-width:90%}
37
- .msg.user{background:var(--accent);color:#fff;margin-left:auto;border-bottom-right-radius:2px}
38
- .msg.sys{background:var(--bg2);color:var(--fg2);border-bottom-left-radius:2px}
39
- .msg.blocked{background:#2a1215;color:var(--red);border:1px solid #4a1c20}
40
- .msg.ok{background:#0f2318;color:var(--accent2);border:1px solid #1a3a28}
41
- .input-row{display:flex;gap:.5rem;padding-top:.5rem;border-top:1px solid var(--border)}
42
- .input-row input{flex:1;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:.6rem .75rem;border-radius:6px;font-size:.82rem;font-family:var(--font);outline:none}
43
- .input-row input:focus{border-color:var(--accent)}
44
- .input-row button{background:var(--accent);color:#fff;border:none;padding:.6rem 1rem;border-radius:6px;font-size:.82rem;cursor:pointer;font-weight:500}
45
- .input-row button:hover{background:var(--accent2);color:#000}
46
-
47
- /* Shields */
48
- .shield{background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:.5rem .75rem;margin-bottom:.4rem;font-family:var(--mono);font-size:.75rem;color:var(--fg2);display:flex;align-items:center;gap:.5rem}
49
- .shield::before{content:"🛡️";font-size:.9rem}
50
-
51
- /* Audit */
52
- .audit-entry{display:grid;grid-template-columns:auto 1fr auto auto;gap:.5rem;padding:.4rem 0;border-bottom:1px solid var(--border);font-size:.72rem;font-family:var(--mono);align-items:center}
53
- .audit-entry .time{color:var(--fg3)}
54
- .audit-entry .tool{color:var(--fg)}
55
- .audit-entry .hash{color:var(--fg3);font-size:.65rem}
56
- .audit-entry .badge{padding:1px 6px;border-radius:4px;font-size:.6rem;font-weight:600}
57
- .badge.ok{background:#0f2318;color:var(--accent2)}
58
- .badge.blocked{background:#2a1215;color:var(--red)}
59
-
60
- /* Stats */
61
- .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin-bottom:1rem}
62
- .stat{background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:.75rem;text-align:center}
63
- .stat .v{font-family:var(--mono);font-size:1.5rem;font-weight:700;color:var(--accent2)}
64
- .stat .l{font-size:.65rem;color:var(--fg3);margin-top:.2rem}
65
-
66
- @media(max-width:768px){.grid{grid-template-columns:1fr;height:auto}.panel{min-height:50vh}.stats{grid-template-columns:repeat(2,1fr)}}
67
- </style>
68
- </head>
69
- <body>
70
- <div class="top">
71
- <h1>⬡ <span>Halyn</span> Dashboard</h1>
72
- <div class="status"><div class="dot"></div> <span id="uptime">connecting...</span></div>
73
- </div>
74
- <div class="grid">
75
- <!-- LEFT: Chat + Command -->
76
- <div class="panel">
77
- <div class="chat">
78
- <div class="messages" id="msgs">
79
- <div class="msg sys">Welcome to Halyn. Type a command or talk naturally.<br><br>Try: <code>observe all</code> · <code>shield deny * delete *</code> · <code>status</code> · <code>audit</code></div>
80
- </div>
81
- <div class="input-row">
82
- <input type="text" id="cmd" placeholder="Type a command... (e.g. 'restart nginx on server-01')" autofocus>
83
- <button onclick="send()">Send</button>
84
- </div>
85
- </div>
86
- </div>
87
- <!-- RIGHT: Status + Shields + Audit -->
88
- <div class="panel">
89
- <div class="stats" id="stats">
90
- <div class="stat"><div class="v" id="s-nodes">0</div><div class="l">Nodes</div></div>
91
- <div class="stat"><div class="v" id="s-shields">0</div><div class="l">Shields</div></div>
92
- <div class="stat"><div class="v" id="s-audit">0</div><div class="l">Audit</div></div>
93
- <div class="stat"><div class="v" id="s-uptime">0s</div><div class="l">Uptime</div></div>
94
- </div>
95
- <h2>🛡️ Shield Rules <span class="count" id="shield-count">0</span></h2>
96
- <div id="shields"></div>
97
- <h2 style="margin-top:1rem">📜 Audit Chain <span class="count" id="audit-count">0</span></h2>
98
- <div id="audit"></div>
99
- </div>
100
- </div>
101
- <script>
102
- const $ = s => document.getElementById(s);
103
-
104
- async function mcp(name, args={}) {
105
- const r = await fetch('/mcp', {
106
- method: 'POST',
107
- headers: {'Content-Type': 'application/json'},
108
- body: JSON.stringify({jsonrpc:'2.0',id:Date.now(),method:'tools/call',params:{name,arguments:args}})
109
- });
110
- const d = await r.json();
111
- return JSON.parse(d.result.content[0].text);
112
- }
113
-
114
- function addMsg(text, cls='sys') {
115
- const d = document.createElement('div');
116
- d.className = 'msg ' + cls;
117
- d.innerHTML = text;
118
- $('msgs').appendChild(d);
119
- $('msgs').scrollTop = $('msgs').scrollHeight;
120
- }
121
-
122
- async function refresh() {
123
- try {
124
- const s = await mcp('halyn_status');
125
- $('s-nodes').textContent = s.nodes;
126
- $('s-shields').textContent = s.shields;
127
- $('s-audit').textContent = s.audit_entries;
128
- $('s-uptime').textContent = s.uptime_seconds + 's';
129
- $('uptime').textContent = 'v' + s.version + ' · ' + s.uptime_seconds + 's uptime';
130
-
131
- const sh = await mcp('halyn_shield_list');
132
- $('shield-count').textContent = sh.count;
133
- $('shields').innerHTML = sh.shields.map(r => '<div class="shield">' + r + '</div>').join('');
134
-
135
- const au = await mcp('halyn_audit', {limit: 20});
136
- $('audit-count').textContent = au.total;
137
- $('audit').innerHTML = au.entries.reverse().map(e => {
138
- const badge = e.result_ok ? '<span class="badge ok">OK</span>' : '<span class="badge blocked">BLOCKED</span>';
139
- return '<div class="audit-entry"><span class="time">' + e.timestamp.split('T')[1].replace('Z','') + '</span><span class="tool">' + e.tool + '</span>' + badge + '<span class="hash">' + e.hash + '</span></div>';
140
- }).join('');
141
- } catch(e) {}
142
- }
143
-
144
- async function send() {
145
- const input = $('cmd');
146
- const cmd = input.value.trim();
147
- if (!cmd) return;
148
- input.value = '';
149
-
150
- addMsg(cmd, 'user');
151
-
152
- const lower = cmd.toLowerCase();
153
-
154
- try {
155
- if (lower === 'status' || lower === 'state') {
156
- const s = await mcp('halyn_status');
157
- addMsg('Nodes: ' + s.nodes + ' · Shields: ' + s.shields + ' · Audit: ' + s.audit_entries + ' · Uptime: ' + s.uptime_seconds + 's', 'ok');
158
- } else if (lower.startsWith('shield ') || lower.startsWith('deny ') || lower.startsWith('limit ')) {
159
- const rule = lower.startsWith('shield ') ? cmd.slice(7) : cmd;
160
- const r = await mcp('halyn_shield_add', {rule});
161
- if (r.error) { addMsg('Error: ' + r.error, 'blocked'); }
162
- else { addMsg('Shield added: ' + r.added + ' (total: ' + r.total_shields + ')', 'ok'); }
163
- } else if (lower === 'shields' || lower === 'rules') {
164
- const r = await mcp('halyn_shield_list');
165
- addMsg(r.shields.length ? r.shields.map(s => '🛡️ ' + s).join('<br>') : 'No shields active.', 'sys');
166
- } else if (lower === 'audit' || lower === 'log' || lower === 'history') {
167
- const r = await mcp('halyn_audit', {limit: 10});
168
- const lines = r.entries.map(e => {
169
- const s = e.result_ok ? '✓' : '✗';
170
- return s + ' ' + e.timestamp.split('T')[1].replace('Z','') + ' ' + e.tool;
171
- });
172
- addMsg(lines.join('<br>') || 'No audit entries yet.', 'sys');
173
- } else if (lower.startsWith('observe') || lower.startsWith('watch') || lower.startsWith('read')) {
174
- const node = cmd.split(' ').slice(1).join(' ') || 'all';
175
- const r = await mcp('halyn_observe', {node});
176
- addMsg('<pre>' + JSON.stringify(r, null, 2) + '</pre>', 'sys');
177
- } else if (lower === 'stop' || lower === 'emergency') {
178
- const r = await mcp('halyn_emergency_stop');
179
- addMsg('⚠️ ' + r.status, 'blocked');
180
- } else if (lower === 'nodes' || lower === 'devices') {
181
- const r = await mcp('halyn_nodes');
182
- addMsg('<pre>' + JSON.stringify(r, null, 2) + '</pre>', 'sys');
183
- } else if (lower === 'help') {
184
- addMsg('Commands:<br>• <b>status</b> — system overview<br>• <b>observe [node]</b> — read device state<br>• <b>shield deny * delete *</b> — add safety rule<br>• <b>shields</b> — list active rules<br>• <b>audit</b> — view action log<br>• <b>nodes</b> — list devices<br>• <b>stop</b> — emergency stop<br>• Or type any action: <i>restart nginx on server-01</i>', 'sys');
185
- } else {
186
- // Treat as action on default node
187
- const parts = cmd.match(/(.+?)\\s+on\\s+(.+)/i);
188
- const node = parts ? parts[2] : 'default';
189
- const command = parts ? parts[1] : cmd;
190
- const r = await mcp('halyn_act', {node, command});
191
- if (r.blocked) {
192
- addMsg('🛡️ BLOCKED: ' + r.reason, 'blocked');
193
- } else {
194
- addMsg('✓ Executed: ' + command + (node !== 'default' ? ' on ' + node : ''), 'ok');
195
- }
196
- }
197
- } catch(e) {
198
- addMsg('Error: ' + e.message, 'blocked');
199
- }
200
-
201
- refresh();
202
- }
203
-
204
- $('cmd').addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
205
- refresh();
206
- setInterval(refresh, 5000);
207
- </script>
208
- </body>
209
- </html>'''
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes