4runr-os 2.10.74 → 2.10.75

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.
@@ -0,0 +1,549 @@
1
+ use crate::app::AppState;
2
+ use ratatui::prelude::*;
3
+ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
4
+ use serde_json::Value;
5
+
6
+ const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
7
+ const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
8
+ const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
9
+ const AMBER_WARN: Color = Color::Rgb(255, 191, 0);
10
+ const SHIELD_ORANGE: Color = Color::Rgb(255, 140, 0);
11
+ const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 240);
12
+ const TEXT_DIM: Color = Color::Rgb(100, 100, 120);
13
+ const TEXT_MUTED: Color = Color::Rgb(60, 60, 80);
14
+ const BG_PANEL: Color = Color::Rgb(18, 18, 25);
15
+
16
+ const ABOUT_SHIELD: &[&str] = &[
17
+ "Shield is 4Runr's input/output safety layer on the Gateway run path.",
18
+ "",
19
+ "Production enforcement (every real run):",
20
+ " • Input check before agent execution starts",
21
+ " • Injection patterns → block (failed run)",
22
+ " • PII in prompts → mask or block per policy",
23
+ " • Output check after agent returns",
24
+ "",
25
+ "This screen shows live Gateway state — no test runs required.",
26
+ "Counters increment as real agent/API traffic is evaluated.",
27
+ ];
28
+
29
+ #[derive(Debug, Clone)]
30
+ pub struct ShieldBlockRow {
31
+ pub id: String,
32
+ pub name: String,
33
+ pub reason: String,
34
+ pub created_at: String,
35
+ }
36
+
37
+ #[derive(Debug, Clone)]
38
+ pub struct ShieldDashboardState {
39
+ pub enabled: bool,
40
+ pub mode: String,
41
+ pub health_status: String,
42
+ pub pii_enabled: bool,
43
+ pub injection_enabled: bool,
44
+ pub injection_action: String,
45
+ pub hallucination_enabled: bool,
46
+ pub hallucination_action: String,
47
+ pub decisions_total: u64,
48
+ pub blocks_total: u64,
49
+ pub masks_total: u64,
50
+ pub rewrites_total: u64,
51
+ pub warnings: Vec<String>,
52
+ pub recent_blocks: Vec<ShieldBlockRow>,
53
+ pub loading: bool,
54
+ pub error: Option<String>,
55
+ pub last_refresh: Option<std::time::Instant>,
56
+ pub auto_refresh_enabled: bool,
57
+ pub auto_refresh_interval: std::time::Duration,
58
+ }
59
+
60
+ impl Default for ShieldDashboardState {
61
+ fn default() -> Self {
62
+ Self {
63
+ enabled: false,
64
+ mode: "off".to_string(),
65
+ health_status: String::new(),
66
+ pii_enabled: false,
67
+ injection_enabled: false,
68
+ injection_action: String::new(),
69
+ hallucination_enabled: false,
70
+ hallucination_action: String::new(),
71
+ decisions_total: 0,
72
+ blocks_total: 0,
73
+ masks_total: 0,
74
+ rewrites_total: 0,
75
+ warnings: Vec::new(),
76
+ recent_blocks: Vec::new(),
77
+ loading: false,
78
+ error: None,
79
+ last_refresh: None,
80
+ auto_refresh_enabled: true,
81
+ auto_refresh_interval: std::time::Duration::from_secs(5),
82
+ }
83
+ }
84
+ }
85
+
86
+ impl ShieldDashboardState {
87
+
88
+ pub fn apply_load(
89
+ &mut self,
90
+ health: &ShieldHealthSnap,
91
+ config: &ShieldConfigSnap,
92
+ metrics: &ShieldMetricsSnap,
93
+ warnings: Vec<String>,
94
+ recent: Vec<ShieldBlockRow>,
95
+ ) {
96
+ self.loading = false;
97
+ self.error = None;
98
+ self.enabled = health.enabled;
99
+ self.mode = health.mode.clone();
100
+ self.health_status = health.status.clone();
101
+ self.pii_enabled = config.pii_enabled;
102
+ self.injection_enabled = config.injection_enabled;
103
+ self.injection_action = config.injection_action.clone();
104
+ self.hallucination_enabled = config.hallucination_enabled;
105
+ self.hallucination_action = config.hallucination_action.clone();
106
+ self.decisions_total = metrics.decisions;
107
+ self.blocks_total = metrics.blocks;
108
+ self.masks_total = metrics.masks;
109
+ self.rewrites_total = metrics.rewrites;
110
+ self.warnings = warnings;
111
+ self.recent_blocks = recent;
112
+ self.last_refresh = Some(std::time::Instant::now());
113
+ }
114
+ }
115
+
116
+ #[derive(Debug, Clone, Default)]
117
+ pub struct ShieldHealthSnap {
118
+ pub enabled: bool,
119
+ pub mode: String,
120
+ pub status: String,
121
+ }
122
+
123
+ #[derive(Debug, Clone, Default)]
124
+ pub struct ShieldConfigSnap {
125
+ pub pii_enabled: bool,
126
+ pub injection_enabled: bool,
127
+ pub injection_action: String,
128
+ pub hallucination_enabled: bool,
129
+ pub hallucination_action: String,
130
+ }
131
+
132
+ #[derive(Debug, Clone, Default)]
133
+ pub struct ShieldMetricsSnap {
134
+ pub decisions: u64,
135
+ pub blocks: u64,
136
+ pub masks: u64,
137
+ pub rewrites: u64,
138
+ }
139
+
140
+ pub fn parse_shield_load(
141
+ health_val: &Value,
142
+ config_val: &Value,
143
+ metrics_val: &Value,
144
+ gateway_health_val: &Value,
145
+ runs_val: &Value,
146
+ ) -> (
147
+ ShieldHealthSnap,
148
+ ShieldConfigSnap,
149
+ ShieldMetricsSnap,
150
+ Vec<String>,
151
+ Vec<ShieldBlockRow>,
152
+ ) {
153
+ let health_obj = health_val.as_object();
154
+ let health = ShieldHealthSnap {
155
+ enabled: health_obj
156
+ .and_then(|o| o.get("enabled"))
157
+ .and_then(|v| v.as_bool())
158
+ .unwrap_or(false),
159
+ mode: health_obj
160
+ .and_then(|o| o.get("mode"))
161
+ .and_then(|v| v.as_str())
162
+ .unwrap_or("off")
163
+ .to_string(),
164
+ status: if health_obj
165
+ .and_then(|o| o.get("enabled"))
166
+ .and_then(|v| v.as_bool())
167
+ .unwrap_or(false)
168
+ {
169
+ "active".to_string()
170
+ } else {
171
+ "disabled".to_string()
172
+ },
173
+ };
174
+
175
+ let cfg = config_val
176
+ .get("config")
177
+ .or(Some(config_val))
178
+ .and_then(|v| v.as_object());
179
+ let pii = cfg.and_then(|c| c.get("pii")).and_then(|v| v.as_object());
180
+ let injection = cfg.and_then(|c| c.get("injection")).and_then(|v| v.as_object());
181
+ let hallucination = cfg
182
+ .and_then(|c| c.get("hallucination"))
183
+ .and_then(|v| v.as_object());
184
+
185
+ let config = ShieldConfigSnap {
186
+ pii_enabled: pii
187
+ .and_then(|o| o.get("enabled"))
188
+ .and_then(|v| v.as_bool())
189
+ .unwrap_or(false),
190
+ injection_enabled: injection
191
+ .and_then(|o| o.get("enabled"))
192
+ .and_then(|v| v.as_bool())
193
+ .unwrap_or(false),
194
+ injection_action: injection
195
+ .and_then(|o| o.get("action"))
196
+ .and_then(|v| v.as_str())
197
+ .unwrap_or("—")
198
+ .to_string(),
199
+ hallucination_enabled: hallucination
200
+ .and_then(|o| o.get("enabled"))
201
+ .and_then(|v| v.as_bool())
202
+ .unwrap_or(false),
203
+ hallucination_action: hallucination
204
+ .and_then(|o| o.get("action"))
205
+ .and_then(|v| v.as_str())
206
+ .unwrap_or("—")
207
+ .to_string(),
208
+ };
209
+
210
+ let metrics = ShieldMetricsSnap {
211
+ decisions: metrics_val
212
+ .get("decisions")
213
+ .and_then(|v| v.as_u64())
214
+ .unwrap_or(0),
215
+ blocks: metrics_val
216
+ .get("blocks")
217
+ .and_then(|v| v.as_u64())
218
+ .unwrap_or(0),
219
+ masks: metrics_val
220
+ .get("masks")
221
+ .and_then(|v| v.as_u64())
222
+ .unwrap_or(0),
223
+ rewrites: metrics_val
224
+ .get("rewrites")
225
+ .and_then(|v| v.as_u64())
226
+ .unwrap_or(0),
227
+ };
228
+
229
+ let mut warnings: Vec<String> = Vec::new();
230
+ if let Some(sh) = gateway_health_val
231
+ .get("checks")
232
+ .and_then(|c| c.get("shield"))
233
+ .and_then(|v| v.as_object())
234
+ {
235
+ if let Some(st) = sh.get("status").and_then(|v| v.as_str()) {
236
+ if st != "ok" && st != "up" {
237
+ warnings.push(format!("Gateway health: {}", st));
238
+ }
239
+ }
240
+ if let Some(arr) = sh.get("warnings").and_then(|v| v.as_array()) {
241
+ for w in arr {
242
+ if let Some(s) = w.as_str() {
243
+ warnings.push(s.to_string());
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ let mut recent: Vec<ShieldBlockRow> = Vec::new();
250
+ let runs_arr = runs_val
251
+ .as_array()
252
+ .or_else(|| runs_val.get("runs").and_then(|v| v.as_array()));
253
+ if let Some(arr) = runs_arr {
254
+ for item in arr {
255
+ let o = match item.as_object() {
256
+ Some(o) => o,
257
+ None => continue,
258
+ };
259
+ let output = o.get("output").and_then(|v| v.as_object());
260
+ let err = output
261
+ .and_then(|oo| oo.get("error"))
262
+ .and_then(|v| v.as_str())
263
+ .unwrap_or("");
264
+ let logs_shield = o
265
+ .get("logs")
266
+ .and_then(|l| l.as_array())
267
+ .map(|arr| {
268
+ arr.iter().any(|e| {
269
+ e.get("message")
270
+ .and_then(|m| m.as_str())
271
+ .map(|s| s.to_lowercase().contains("shield"))
272
+ .unwrap_or(false)
273
+ })
274
+ })
275
+ .unwrap_or(false);
276
+ if !err.to_lowercase().contains("shield") && !logs_shield {
277
+ continue;
278
+ }
279
+ let id = o
280
+ .get("id")
281
+ .and_then(|v| v.as_str())
282
+ .unwrap_or("?")
283
+ .to_string();
284
+ let name = o
285
+ .get("name")
286
+ .and_then(|v| v.as_str())
287
+ .unwrap_or("Run")
288
+ .to_string();
289
+ let reason = output
290
+ .and_then(|oo| oo.get("reason"))
291
+ .and_then(|v| v.as_str())
292
+ .unwrap_or(err)
293
+ .to_string();
294
+ let created_at = o
295
+ .get("createdAt")
296
+ .and_then(|v| v.as_str())
297
+ .map(|s| s.chars().take(19).collect())
298
+ .unwrap_or_else(|| "—".to_string());
299
+ recent.push(ShieldBlockRow {
300
+ id,
301
+ name,
302
+ reason,
303
+ created_at,
304
+ });
305
+ }
306
+ }
307
+ recent.truncate(12);
308
+
309
+ (health, config, metrics, warnings, recent)
310
+ }
311
+
312
+ pub fn render(f: &mut Frame, state: &AppState) {
313
+ let area = f.size();
314
+ let sd = &state.shield_dashboard;
315
+
316
+ use ratatui::layout::{Constraint, Direction, Layout};
317
+
318
+ let live = if sd.auto_refresh_enabled { " · LIVE" } else { "" };
319
+ let header_title = format!(
320
+ " 🛡️ Shield — {} · {} block(s) · {} mask(s){} ",
321
+ sd.mode.to_uppercase(),
322
+ sd.blocks_total,
323
+ sd.masks_total,
324
+ live
325
+ );
326
+
327
+ let chunks = Layout::default()
328
+ .direction(Direction::Vertical)
329
+ .constraints([
330
+ Constraint::Length(3),
331
+ Constraint::Min(10),
332
+ Constraint::Length(8),
333
+ Constraint::Length(3),
334
+ ])
335
+ .split(area);
336
+
337
+ let header = Block::default()
338
+ .title(header_title)
339
+ .borders(Borders::ALL)
340
+ .border_style(Style::default().fg(SHIELD_ORANGE))
341
+ .style(Style::default().bg(BG_PANEL));
342
+ f.render_widget(header, chunks[0]);
343
+
344
+ let mid = Layout::default()
345
+ .direction(Direction::Horizontal)
346
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
347
+ .split(chunks[1]);
348
+
349
+ render_status_panel(f, mid[0], sd);
350
+ render_metrics_panel(f, mid[1], sd);
351
+ render_enforcement_panel(f, chunks[2], sd);
352
+
353
+ let footer_msg = if sd.loading {
354
+ "Loading live Shield state from Gateway…".to_string()
355
+ } else if let Some(e) = &sd.error {
356
+ e.clone()
357
+ } else {
358
+ "R Refresh · A Live toggle · ESC Close".to_string()
359
+ };
360
+ let footer_color = if sd.error.is_some() {
361
+ Color::Rgb(255, 69, 69)
362
+ } else {
363
+ TEXT_DIM
364
+ };
365
+ let footer = Block::default()
366
+ .title(" ⌨️ Actions ")
367
+ .borders(Borders::ALL)
368
+ .border_style(Style::default().fg(TEXT_MUTED));
369
+ let inner = footer.inner(chunks[3]);
370
+ f.render_widget(footer, chunks[3]);
371
+ f.render_widget(
372
+ Paragraph::new(footer_msg)
373
+ .style(Style::default().fg(footer_color))
374
+ .alignment(Alignment::Center),
375
+ inner,
376
+ );
377
+ }
378
+
379
+ fn render_status_panel(f: &mut Frame, area: Rect, sd: &ShieldDashboardState) {
380
+ let block = Block::default()
381
+ .title(" Status & detectors ")
382
+ .borders(Borders::ALL)
383
+ .border_style(Style::default().fg(CYBER_CYAN))
384
+ .style(Style::default().bg(BG_PANEL));
385
+ let inner = block.inner(area);
386
+ f.render_widget(block, area);
387
+
388
+ let mode_color = match sd.mode.as_str() {
389
+ "enforce" => NEON_GREEN,
390
+ "monitor" => AMBER_WARN,
391
+ _ => TEXT_MUTED,
392
+ };
393
+
394
+ let mut lines = vec![
395
+ Line::from(vec![
396
+ Span::styled("Enabled: ", Style::default().fg(TEXT_DIM)),
397
+ Span::styled(
398
+ if sd.enabled { "YES" } else { "NO" },
399
+ Style::default()
400
+ .fg(if sd.enabled { NEON_GREEN } else { TEXT_MUTED })
401
+ .bold(),
402
+ ),
403
+ ]),
404
+ Line::from(vec![
405
+ Span::styled("Mode: ", Style::default().fg(TEXT_DIM)),
406
+ Span::styled(
407
+ sd.mode.to_uppercase(),
408
+ Style::default().fg(mode_color).bold(),
409
+ ),
410
+ ]),
411
+ Line::from(""),
412
+ Line::from("Detectors (production path):").style(Style::default().fg(TEXT_DIM)),
413
+ detector_line("PII", sd.pii_enabled, "mask on input/output".to_string()),
414
+ detector_line(
415
+ "Injection",
416
+ sd.injection_enabled,
417
+ format!("action: {}", sd.injection_action),
418
+ ),
419
+ detector_line(
420
+ "Hallucination",
421
+ sd.hallucination_enabled,
422
+ format!("action: {}", sd.hallucination_action),
423
+ ),
424
+ ];
425
+
426
+ if !sd.warnings.is_empty() {
427
+ lines.push(Line::from(""));
428
+ lines.push(
429
+ Line::from("Warnings:")
430
+ .style(Style::default().fg(AMBER_WARN).bold()),
431
+ );
432
+ for w in &sd.warnings {
433
+ lines.push(Line::from(format!(" ⚠ {}", w)).style(Style::default().fg(AMBER_WARN)));
434
+ }
435
+ }
436
+
437
+ lines.push(Line::from(""));
438
+ for line in ABOUT_SHIELD.iter().take(4) {
439
+ lines.push(Line::from(*line).style(Style::default().fg(TEXT_MUTED)));
440
+ }
441
+
442
+ f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
443
+ }
444
+
445
+ fn detector_line(name: &str, on: bool, detail: String) -> Line<'static> {
446
+ Line::from(vec![
447
+ Span::raw(" "),
448
+ Span::styled(
449
+ if on { "●" } else { "○" },
450
+ Style::default().fg(if on { NEON_GREEN } else { TEXT_MUTED }),
451
+ ),
452
+ Span::styled(format!(" {:<14}", name), Style::default().fg(TEXT_PRIMARY)),
453
+ Span::styled(detail, Style::default().fg(TEXT_DIM)),
454
+ ])
455
+ }
456
+
457
+ fn render_metrics_panel(f: &mut Frame, area: Rect, sd: &ShieldDashboardState) {
458
+ let block = Block::default()
459
+ .title(" Gateway metrics (cumulative) ")
460
+ .borders(Borders::ALL)
461
+ .border_style(Style::default().fg(BRAND_PURPLE))
462
+ .style(Style::default().bg(BG_PANEL));
463
+ let inner = block.inner(area);
464
+ f.render_widget(block, area);
465
+
466
+ let lines = vec![
467
+ Line::from(""),
468
+ metric_line("Decisions total", sd.decisions_total, TEXT_PRIMARY),
469
+ metric_line("Blocks total", sd.blocks_total, SHIELD_ORANGE),
470
+ metric_line("Masks total", sd.masks_total, CYBER_CYAN),
471
+ metric_line("Rewrites total", sd.rewrites_total, AMBER_WARN),
472
+ Line::from(""),
473
+ Line::from("Source: Gateway Prometheus /metrics").style(Style::default().fg(TEXT_MUTED)),
474
+ Line::from("Updates on refresh — counts all production runs.").style(Style::default().fg(TEXT_MUTED)),
475
+ Line::from(""),
476
+ Line::from(ABOUT_SHIELD[4]).style(Style::default().fg(TEXT_DIM)),
477
+ Line::from(ABOUT_SHIELD[5]).style(Style::default().fg(TEXT_DIM)),
478
+ Line::from(ABOUT_SHIELD[6]).style(Style::default().fg(TEXT_DIM)),
479
+ Line::from(ABOUT_SHIELD[7]).style(Style::default().fg(TEXT_DIM)),
480
+ ];
481
+
482
+ f.render_widget(Paragraph::new(lines), inner);
483
+ }
484
+
485
+ fn metric_line(label: &str, value: u64, color: Color) -> Line<'static> {
486
+ Line::from(vec![
487
+ Span::styled(format!(" {:<18}", label), Style::default().fg(TEXT_DIM)),
488
+ Span::styled(
489
+ value.to_string(),
490
+ Style::default().fg(color).bold(),
491
+ ),
492
+ ])
493
+ }
494
+
495
+ fn render_enforcement_panel(f: &mut Frame, area: Rect, sd: &ShieldDashboardState) {
496
+ let block = Block::default()
497
+ .title(format!(
498
+ " Recent enforcement ({} blocked run(s) in history) ",
499
+ sd.recent_blocks.len()
500
+ ))
501
+ .borders(Borders::ALL)
502
+ .border_style(Style::default().fg(SHIELD_ORANGE))
503
+ .style(Style::default().bg(BG_PANEL));
504
+ let inner = block.inner(area);
505
+ f.render_widget(block, area);
506
+
507
+ if sd.recent_blocks.is_empty() {
508
+ let empty = vec![
509
+ Line::from(""),
510
+ Line::from("No blocked runs in Gateway history yet.").style(Style::default().fg(TEXT_DIM)),
511
+ Line::from("Shield is active on every run — blocks appear here when production").style(Style::default().fg(TEXT_MUTED)),
512
+ Line::from("traffic triggers injection/PII policy (failed status + SHIELD in Run Manager).").style(Style::default().fg(TEXT_MUTED)),
513
+ ];
514
+ f.render_widget(Paragraph::new(empty), inner);
515
+ return;
516
+ }
517
+
518
+ let items: Vec<ListItem> = sd
519
+ .recent_blocks
520
+ .iter()
521
+ .map(|r| {
522
+ let short_id = if r.id.len() > 8 {
523
+ &r.id[r.id.len() - 8..]
524
+ } else {
525
+ &r.id
526
+ };
527
+ ListItem::new(Line::from(vec![
528
+ Span::styled(
529
+ format!("{} ", r.created_at),
530
+ Style::default().fg(TEXT_MUTED),
531
+ ),
532
+ Span::styled(
533
+ format!("[{}] ", short_id),
534
+ Style::default().fg(CYBER_CYAN),
535
+ ),
536
+ Span::styled(
537
+ format!("{} — ", r.name),
538
+ Style::default().fg(TEXT_PRIMARY),
539
+ ),
540
+ Span::styled(
541
+ r.reason.chars().take(48).collect::<String>(),
542
+ Style::default().fg(SHIELD_ORANGE),
543
+ ),
544
+ ]))
545
+ })
546
+ .collect();
547
+
548
+ f.render_widget(List::new(items), inner);
549
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "4runr-os",
3
- "version": "2.10.74",
3
+ "version": "2.10.75",
4
4
  "type": "module",
5
- "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.74: Shield visible in Run Manager, System Status, monitoring; shield demo/probe/status commands; stronger tools smoke.",
5
+ "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.10.75: Dedicated Shield dashboard (shield command) live metrics, detectors, enforcement history.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "4runr": "dist/index.js",