neoagent 2.4.4-beta.0 → 2.4.4-beta.4

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 (47) hide show
  1. package/README.md +5 -3
  2. package/docs/capabilities.md +16 -7
  3. package/docs/index.md +1 -0
  4. package/docs/security-boundaries.md +122 -0
  5. package/docs/supermemory-memory-review.md +852 -0
  6. package/flutter_app/lib/features/memory/views/retrieval_inspector_view.dart +128 -0
  7. package/flutter_app/lib/main.dart +3 -0
  8. package/flutter_app/lib/main_app_shell.dart +22 -0
  9. package/flutter_app/lib/main_controller.dart +36 -1
  10. package/flutter_app/lib/main_operations.dart +13 -0
  11. package/flutter_app/lib/main_security.dart +971 -0
  12. package/flutter_app/lib/main_settings.dart +61 -0
  13. package/flutter_app/lib/src/backend_client.dart +60 -3
  14. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
  15. package/flutter_app/pubspec.lock +32 -0
  16. package/flutter_app/pubspec.yaml +1 -0
  17. package/lib/schema_migrations.js +237 -0
  18. package/package.json +4 -2
  19. package/server/db/database.js +3 -0
  20. package/server/http/routes.js +2 -1
  21. package/server/public/.last_build_id +1 -1
  22. package/server/public/assets/NOTICES +86 -0
  23. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  24. package/server/public/flutter_bootstrap.js +1 -1
  25. package/server/public/main.dart.js +80911 -79117
  26. package/server/routes/memory.js +39 -2
  27. package/server/routes/security.js +112 -0
  28. package/server/services/ai/engine.js +267 -10
  29. package/server/services/ai/systemPrompt.js +13 -2
  30. package/server/services/cli/shell_worker.js +135 -0
  31. package/server/services/cli/shell_worker_pool.js +125 -0
  32. package/server/services/manager.js +20 -1
  33. package/server/services/memory/consolidation.js +111 -0
  34. package/server/services/memory/embedding_index.js +175 -0
  35. package/server/services/memory/embeddings.js +22 -2
  36. package/server/services/memory/evaluation.js +187 -0
  37. package/server/services/memory/ingestion_chunking.js +191 -0
  38. package/server/services/memory/ingestion_documents.js +96 -26
  39. package/server/services/memory/intelligence.js +3 -1
  40. package/server/services/memory/manager.js +855 -40
  41. package/server/services/memory/policy.js +0 -40
  42. package/server/services/memory/retrieval_reasoning.js +191 -0
  43. package/server/services/runtime/manager.js +7 -0
  44. package/server/services/security/approval_gate_service.js +93 -0
  45. package/server/services/security/tool_categories.js +105 -0
  46. package/server/services/security/tool_policy_service.js +92 -0
  47. package/server/services/security/tool_security_hook.js +77 -0
@@ -0,0 +1,971 @@
1
+ part of 'main.dart';
2
+
3
+ // ── Tool approval request model ───────────────────────────────────────────────
4
+
5
+ class ToolApprovalRequest {
6
+ const ToolApprovalRequest({
7
+ required this.approvalId,
8
+ required this.runId,
9
+ required this.toolName,
10
+ required this.toolArgs,
11
+ required this.category,
12
+ required this.expiresAt,
13
+ });
14
+
15
+ factory ToolApprovalRequest.fromJson(Map<String, dynamic> json) {
16
+ return ToolApprovalRequest(
17
+ approvalId: json['approvalId']?.toString() ?? '',
18
+ runId: json['runId']?.toString() ?? '',
19
+ toolName: json['toolName']?.toString() ?? '',
20
+ toolArgs: json['toolArgs'] is Map
21
+ ? Map<String, dynamic>.from(json['toolArgs'] as Map)
22
+ : const <String, dynamic>{},
23
+ category: json['category']?.toString() ?? 'unknown',
24
+ expiresAt: json['expiresAt'] != null
25
+ ? DateTime.tryParse(json['expiresAt'].toString()) ??
26
+ DateTime.now().add(const Duration(seconds: 30))
27
+ : DateTime.now().add(const Duration(seconds: 30)),
28
+ );
29
+ }
30
+
31
+ final String approvalId;
32
+ final String runId;
33
+ final String toolName;
34
+ final Map<String, dynamic> toolArgs;
35
+ final String category;
36
+ final DateTime expiresAt;
37
+ }
38
+
39
+ // ── Category metadata ─────────────────────────────────────────────────────────
40
+
41
+ class _CategoryInfo {
42
+ const _CategoryInfo({
43
+ required this.label,
44
+ required this.subtitle,
45
+ required this.icon,
46
+ required this.color,
47
+ required this.riskLevel,
48
+ });
49
+ final String label;
50
+ final String subtitle;
51
+ final IconData icon;
52
+ final Color color;
53
+ final String riskLevel; // 'low' | 'medium' | 'high' | 'critical'
54
+ }
55
+
56
+ const _kCategoryInfo = <String, _CategoryInfo>{
57
+ 'shell': _CategoryInfo(
58
+ label: 'Shell Commands',
59
+ subtitle: 'Run arbitrary commands on your machine or VM.',
60
+ icon: Icons.terminal_rounded,
61
+ color: Color(0xFFE53935),
62
+ riskLevel: 'critical',
63
+ ),
64
+ 'file_write': _CategoryInfo(
65
+ label: 'File Writes',
66
+ subtitle: 'Create or modify files in your workspace.',
67
+ icon: Icons.edit_document,
68
+ color: Color(0xFFF4511E),
69
+ riskLevel: 'high',
70
+ ),
71
+ 'android_privileged': _CategoryInfo(
72
+ label: 'Android Control',
73
+ subtitle: 'Run shell commands or install apps on your Android device.',
74
+ icon: Icons.android_rounded,
75
+ color: Color(0xFF43A047),
76
+ riskLevel: 'high',
77
+ ),
78
+ 'desktop_control': _CategoryInfo(
79
+ label: 'Desktop Control',
80
+ subtitle: 'Click, type, and interact with desktop apps.',
81
+ icon: Icons.desktop_windows_rounded,
82
+ color: Color(0xFF1E88E5),
83
+ riskLevel: 'medium',
84
+ ),
85
+ 'browser_privileged': _CategoryInfo(
86
+ label: 'Browser Scripting',
87
+ subtitle: 'Execute JavaScript inside your browser session.',
88
+ icon: Icons.code_rounded,
89
+ color: Color(0xFF8E24AA),
90
+ riskLevel: 'high',
91
+ ),
92
+ 'network_write': _CategoryInfo(
93
+ label: 'Network Write Requests',
94
+ subtitle: 'Send POST / PUT / DELETE requests to external APIs.',
95
+ icon: Icons.http_rounded,
96
+ color: Color(0xFF00897B),
97
+ riskLevel: 'medium',
98
+ ),
99
+ 'skill_mutation': _CategoryInfo(
100
+ label: 'Skill & Widget Changes',
101
+ subtitle: 'Create, update, or delete skills and widgets.',
102
+ icon: Icons.extension_rounded,
103
+ color: Color(0xFFFB8C00),
104
+ riskLevel: 'medium',
105
+ ),
106
+ };
107
+
108
+ _CategoryInfo _categoryInfo(String category) {
109
+ return _kCategoryInfo[category] ??
110
+ _CategoryInfo(
111
+ label: category,
112
+ subtitle: 'Controls access to $category tools.',
113
+ icon: Icons.lock_outline,
114
+ color: Colors.grey,
115
+ riskLevel: 'medium',
116
+ );
117
+ }
118
+
119
+ Color _riskColor(String level) {
120
+ return switch (level) {
121
+ 'critical' => const Color(0xFFE53935),
122
+ 'high' => const Color(0xFFF4511E),
123
+ 'medium' => const Color(0xFFFF8F00),
124
+ _ => Colors.grey,
125
+ };
126
+ }
127
+
128
+ // ── Notification service ──────────────────────────────────────────────────────
129
+
130
+ class _SecurityNotificationService {
131
+ static const _channelId = 'tool_approval';
132
+ static const _channelName = 'Tool Approval';
133
+ static const _approveActionId = 'approve';
134
+ static const _denyActionId = 'deny';
135
+
136
+ static FlutterLocalNotificationsPlugin? _plugin;
137
+
138
+ static Future<FlutterLocalNotificationsPlugin?> _getPlugin() async {
139
+ if (_plugin != null) return _plugin;
140
+ try {
141
+ final plugin = FlutterLocalNotificationsPlugin();
142
+ final androidSettings = const AndroidInitializationSettings('@mipmap/ic_launcher');
143
+ final darwinSettings = DarwinInitializationSettings(
144
+ requestAlertPermission: false,
145
+ requestBadgePermission: false,
146
+ requestSoundPermission: false,
147
+ notificationCategories: <DarwinNotificationCategory>[
148
+ DarwinNotificationCategory(
149
+ 'tool_approval',
150
+ actions: <DarwinNotificationAction>[
151
+ DarwinNotificationAction.plain(_approveActionId, 'Allow'),
152
+ DarwinNotificationAction.plain(_denyActionId, 'Deny',
153
+ options: <DarwinNotificationActionOption>{
154
+ DarwinNotificationActionOption.destructive,
155
+ }),
156
+ ],
157
+ ),
158
+ ],
159
+ );
160
+ await plugin.initialize(
161
+ InitializationSettings(
162
+ android: androidSettings,
163
+ iOS: darwinSettings,
164
+ macOS: darwinSettings,
165
+ ),
166
+ onDidReceiveNotificationResponse: _onNotificationResponse,
167
+ onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationResponse,
168
+ );
169
+ _plugin = plugin;
170
+ return plugin;
171
+ } catch (_) {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ static void _onNotificationResponse(NotificationResponse response) {
177
+ _handleNotificationAction(response.id, response.actionId, response.payload);
178
+ }
179
+
180
+ @pragma('vm:entry-point')
181
+ static void _onBackgroundNotificationResponse(NotificationResponse response) {
182
+ _handleNotificationAction(response.id, response.actionId, response.payload);
183
+ }
184
+
185
+ static void _handleNotificationAction(int? id, String? actionId, String? payload) {
186
+ // No-op in background; the app will handle it on resume via foreground listener.
187
+ // Foreground case is handled directly by the approval gate service.
188
+ }
189
+
190
+ static Future<void> requestPermission() async {
191
+ final plugin = await _getPlugin();
192
+ if (plugin == null) return;
193
+ if (!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
194
+ await plugin
195
+ .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
196
+ ?.requestNotificationsPermission();
197
+ await plugin
198
+ .resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
199
+ ?.requestPermissions(alert: true, badge: false, sound: false);
200
+ }
201
+ }
202
+
203
+ static Future<void> showApprovalNotification(ToolApprovalRequest req) async {
204
+ await requestPermission();
205
+ final plugin = await _getPlugin();
206
+ if (plugin == null) return;
207
+
208
+ final info = _categoryInfo(req.category);
209
+ final body = 'Agent wants to use ${req.toolName}. Tap to decide.';
210
+
211
+ final androidDetails = AndroidNotificationDetails(
212
+ _channelId,
213
+ _channelName,
214
+ channelDescription: 'Approval requests for sensitive agent tools',
215
+ importance: Importance.high,
216
+ priority: Priority.high,
217
+ ticker: 'Tool approval required',
218
+ color: info.color,
219
+ actions: <AndroidNotificationAction>[
220
+ const AndroidNotificationAction(_approveActionId, 'Allow'),
221
+ const AndroidNotificationAction(_denyActionId, 'Deny'),
222
+ ],
223
+ );
224
+
225
+ final darwinDetails = DarwinNotificationDetails(
226
+ categoryIdentifier: 'tool_approval',
227
+ );
228
+
229
+ await plugin.show(
230
+ req.approvalId.hashCode.abs() % 100000,
231
+ '${info.label} approval needed',
232
+ body,
233
+ NotificationDetails(android: androidDetails, iOS: darwinDetails, macOS: darwinDetails),
234
+ payload: req.approvalId,
235
+ );
236
+ }
237
+
238
+ static Future<void> cancelApprovalNotification(String approvalId) async {
239
+ final plugin = await _getPlugin();
240
+ await plugin?.cancel(approvalId.hashCode.abs() % 100000);
241
+ }
242
+ }
243
+
244
+ // ── Security settings screen ──────────────────────────────────────────────────
245
+
246
+ class MainSecurity extends StatefulWidget {
247
+ const MainSecurity({super.key, required this.controller});
248
+ final NeoAgentController controller;
249
+
250
+ @override
251
+ State<MainSecurity> createState() => _MainSecurityState();
252
+ }
253
+
254
+ class _MainSecurityState extends State<MainSecurity> {
255
+ Map<String, String> _policies = const <String, String>{};
256
+ String _mode = 'default';
257
+ bool _loading = true;
258
+ String? _error;
259
+
260
+ @override
261
+ void initState() {
262
+ super.initState();
263
+ _load();
264
+ }
265
+
266
+ Future<void> _load() async {
267
+ try {
268
+ final result = await widget.controller.backendClient.fetchSecurityPolicies(
269
+ widget.controller.backendUrl,
270
+ );
271
+ final raw = result['policies'];
272
+ Map<String, String> loaded = const <String, String>{};
273
+ if (raw is Map) {
274
+ loaded = Map<String, String>.from(
275
+ raw.map((k, v) => MapEntry(k.toString(), v.toString())));
276
+ }
277
+ setState(() {
278
+ _policies = loaded;
279
+ _mode = result['mode']?.toString() ?? 'default';
280
+ _loading = false;
281
+ });
282
+ } catch (e) {
283
+ setState(() { _error = e.toString(); _loading = false; });
284
+ }
285
+ }
286
+
287
+ Future<void> _setMode(String mode) async {
288
+ final prev = _mode;
289
+ setState(() => _mode = mode);
290
+ try {
291
+ await widget.controller.backendClient.saveSecurityMode(
292
+ widget.controller.backendUrl, mode,
293
+ );
294
+ } catch (e) {
295
+ setState(() => _mode = prev);
296
+ if (mounted) {
297
+ ScaffoldMessenger.of(context).showSnackBar(
298
+ SnackBar(content: Text('Failed to save: $e'), backgroundColor: Colors.red),
299
+ );
300
+ }
301
+ }
302
+ }
303
+
304
+ Future<void> _setPolicy(String category, String policy) async {
305
+ final prev = _policies[category];
306
+ setState(() => _policies = {..._policies, category: policy});
307
+ try {
308
+ await widget.controller.backendClient.saveSecurityPolicy(
309
+ widget.controller.backendUrl, category: category, policy: policy,
310
+ );
311
+ } catch (e) {
312
+ setState(() => _policies = {..._policies, category: prev ?? 'require_approval'});
313
+ if (mounted) {
314
+ ScaffoldMessenger.of(context).showSnackBar(
315
+ SnackBar(content: Text('Failed to save: $e'), backgroundColor: Colors.red),
316
+ );
317
+ }
318
+ }
319
+ }
320
+
321
+ @override
322
+ Widget build(BuildContext context) {
323
+ final colorScheme = Theme.of(context).colorScheme;
324
+ return Scaffold(
325
+ appBar: AppBar(
326
+ title: const Text('Tool Permissions'),
327
+ actions: <Widget>[
328
+ IconButton(
329
+ icon: const Icon(Icons.refresh_rounded),
330
+ tooltip: 'Refresh',
331
+ onPressed: _load,
332
+ ),
333
+ ],
334
+ ),
335
+ body: _loading
336
+ ? const Center(child: CircularProgressIndicator.adaptive())
337
+ : _error != null
338
+ ? _ErrorView(error: _error!, onRetry: _load)
339
+ : ListView(
340
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
341
+ children: <Widget>[
342
+ _GlobalModeCard(mode: _mode, onChanged: _setMode),
343
+ const SizedBox(height: 16),
344
+ if (_mode == 'allow_all')
345
+ _InfoBanner(
346
+ icon: Icons.warning_amber_rounded,
347
+ color: colorScheme.errorContainer,
348
+ textColor: colorScheme.onErrorContainer,
349
+ message: 'All tools are allowed — the agent can use any capability without asking. '
350
+ 'Switch to "Default" or "Always ask" to re-enable approval checks.',
351
+ )
352
+ else ...<Widget>[
353
+ if (_mode == 'always_ask')
354
+ _InfoBanner(
355
+ icon: Icons.info_outline_rounded,
356
+ color: colorScheme.secondaryContainer,
357
+ textColor: colorScheme.onSecondaryContainer,
358
+ message: 'The agent will ask before every sensitive tool, '
359
+ 'regardless of per-category settings below.',
360
+ ),
361
+ const SizedBox(height: 4),
362
+ const Padding(
363
+ padding: EdgeInsets.symmetric(vertical: 6, horizontal: 4),
364
+ child: Text(
365
+ 'Per-category permissions',
366
+ style: TextStyle(fontWeight: FontWeight.w700, fontSize: 13),
367
+ ),
368
+ ),
369
+ ..._policies.entries.map((e) => Padding(
370
+ padding: const EdgeInsets.only(bottom: 8),
371
+ child: _PolicyCard(
372
+ category: e.key,
373
+ policy: e.value,
374
+ dimmed: _mode == 'always_ask',
375
+ onChanged: (p) => _setPolicy(e.key, p),
376
+ ),
377
+ )),
378
+ ],
379
+ ],
380
+ ),
381
+ );
382
+ }
383
+ }
384
+
385
+ class _ErrorView extends StatelessWidget {
386
+ const _ErrorView({required this.error, required this.onRetry});
387
+ final String error;
388
+ final VoidCallback onRetry;
389
+
390
+ @override
391
+ Widget build(BuildContext context) {
392
+ return Center(
393
+ child: Column(
394
+ mainAxisSize: MainAxisSize.min,
395
+ children: <Widget>[
396
+ const Icon(Icons.error_outline, size: 48, color: Colors.red),
397
+ const SizedBox(height: 12),
398
+ Text('Failed to load policies', style: Theme.of(context).textTheme.titleMedium),
399
+ const SizedBox(height: 6),
400
+ Text(error, style: const TextStyle(fontSize: 12, color: Colors.grey), textAlign: TextAlign.center),
401
+ const SizedBox(height: 16),
402
+ OutlinedButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('Retry')),
403
+ ],
404
+ ),
405
+ );
406
+ }
407
+ }
408
+
409
+ class _InfoBanner extends StatelessWidget {
410
+ const _InfoBanner({
411
+ required this.icon,
412
+ required this.color,
413
+ required this.textColor,
414
+ required this.message,
415
+ });
416
+ final IconData icon;
417
+ final Color color;
418
+ final Color textColor;
419
+ final String message;
420
+
421
+ @override
422
+ Widget build(BuildContext context) {
423
+ return Container(
424
+ margin: const EdgeInsets.only(bottom: 8),
425
+ padding: const EdgeInsets.all(12),
426
+ decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
427
+ child: Row(
428
+ crossAxisAlignment: CrossAxisAlignment.start,
429
+ children: <Widget>[
430
+ Icon(icon, color: textColor, size: 18),
431
+ const SizedBox(width: 10),
432
+ Expanded(child: Text(message, style: TextStyle(color: textColor, fontSize: 13))),
433
+ ],
434
+ ),
435
+ );
436
+ }
437
+ }
438
+
439
+ class _GlobalModeCard extends StatelessWidget {
440
+ const _GlobalModeCard({required this.mode, required this.onChanged});
441
+ final String mode;
442
+ final ValueChanged<String> onChanged;
443
+
444
+ @override
445
+ Widget build(BuildContext context) {
446
+ final colorScheme = Theme.of(context).colorScheme;
447
+ return Card(
448
+ elevation: 0,
449
+ shape: RoundedRectangleBorder(
450
+ borderRadius: BorderRadius.circular(14),
451
+ side: BorderSide(color: colorScheme.outlineVariant),
452
+ ),
453
+ child: Padding(
454
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
455
+ child: Column(
456
+ crossAxisAlignment: CrossAxisAlignment.start,
457
+ children: <Widget>[
458
+ Row(
459
+ children: <Widget>[
460
+ Icon(Icons.tune_rounded, size: 18, color: colorScheme.primary),
461
+ const SizedBox(width: 8),
462
+ const Text('Global security mode', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
463
+ ],
464
+ ),
465
+ const SizedBox(height: 12),
466
+ _ModeOption(
467
+ value: 'allow_all',
468
+ current: mode,
469
+ label: 'Allow all',
470
+ subtitle: 'No approval prompts — agent runs without interruption.',
471
+ icon: Icons.lock_open_rounded,
472
+ color: Colors.green,
473
+ onTap: () => onChanged('allow_all'),
474
+ ),
475
+ const SizedBox(height: 6),
476
+ _ModeOption(
477
+ value: 'default',
478
+ current: mode,
479
+ label: 'Default (recommended)',
480
+ subtitle: 'Use per-category settings below.',
481
+ icon: Icons.shield_outlined,
482
+ color: colorScheme.primary,
483
+ onTap: () => onChanged('default'),
484
+ ),
485
+ const SizedBox(height: 6),
486
+ _ModeOption(
487
+ value: 'always_ask',
488
+ current: mode,
489
+ label: 'Always ask',
490
+ subtitle: 'Every sensitive tool requires approval, every time.',
491
+ icon: Icons.pan_tool_outlined,
492
+ color: Colors.orange,
493
+ onTap: () => onChanged('always_ask'),
494
+ ),
495
+ ],
496
+ ),
497
+ ),
498
+ );
499
+ }
500
+ }
501
+
502
+ class _ModeOption extends StatelessWidget {
503
+ const _ModeOption({
504
+ required this.value,
505
+ required this.current,
506
+ required this.label,
507
+ required this.subtitle,
508
+ required this.icon,
509
+ required this.color,
510
+ required this.onTap,
511
+ });
512
+ final String value;
513
+ final String current;
514
+ final String label;
515
+ final String subtitle;
516
+ final IconData icon;
517
+ final Color color;
518
+ final VoidCallback onTap;
519
+
520
+ @override
521
+ Widget build(BuildContext context) {
522
+ final selected = value == current;
523
+ final colorScheme = Theme.of(context).colorScheme;
524
+ return GestureDetector(
525
+ onTap: onTap,
526
+ child: AnimatedContainer(
527
+ duration: const Duration(milliseconds: 150),
528
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
529
+ decoration: BoxDecoration(
530
+ color: selected ? color.withAlpha(20) : Colors.transparent,
531
+ borderRadius: BorderRadius.circular(10),
532
+ border: Border.all(
533
+ color: selected ? color : colorScheme.outlineVariant,
534
+ width: selected ? 1.5 : 1,
535
+ ),
536
+ ),
537
+ child: Row(
538
+ children: <Widget>[
539
+ Icon(icon, size: 18, color: selected ? color : colorScheme.onSurfaceVariant),
540
+ const SizedBox(width: 10),
541
+ Expanded(
542
+ child: Column(
543
+ crossAxisAlignment: CrossAxisAlignment.start,
544
+ children: <Widget>[
545
+ Text(label,
546
+ style: TextStyle(
547
+ fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
548
+ fontSize: 13,
549
+ color: selected ? color : null,
550
+ )),
551
+ Text(subtitle, style: const TextStyle(fontSize: 11, color: Colors.grey)),
552
+ ],
553
+ ),
554
+ ),
555
+ if (selected) Icon(Icons.check_circle, size: 16, color: color),
556
+ ],
557
+ ),
558
+ ),
559
+ );
560
+ }
561
+ }
562
+
563
+ class _PolicyCard extends StatelessWidget {
564
+ const _PolicyCard({
565
+ required this.category,
566
+ required this.policy,
567
+ required this.dimmed,
568
+ required this.onChanged,
569
+ });
570
+ final String category;
571
+ final String policy;
572
+ final bool dimmed;
573
+ final ValueChanged<String> onChanged;
574
+
575
+ @override
576
+ Widget build(BuildContext context) {
577
+ final info = _categoryInfo(category);
578
+ final colorScheme = Theme.of(context).colorScheme;
579
+ final riskColor = _riskColor(info.riskLevel);
580
+
581
+ return Opacity(
582
+ opacity: dimmed ? 0.55 : 1.0,
583
+ child: Card(
584
+ elevation: 0,
585
+ shape: RoundedRectangleBorder(
586
+ borderRadius: BorderRadius.circular(12),
587
+ side: BorderSide(color: colorScheme.outlineVariant),
588
+ ),
589
+ child: Padding(
590
+ padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
591
+ child: Column(
592
+ crossAxisAlignment: CrossAxisAlignment.start,
593
+ children: <Widget>[
594
+ Row(
595
+ children: <Widget>[
596
+ Container(
597
+ padding: const EdgeInsets.all(7),
598
+ decoration: BoxDecoration(
599
+ color: info.color.withAlpha(22),
600
+ borderRadius: BorderRadius.circular(8),
601
+ ),
602
+ child: Icon(info.icon, size: 16, color: info.color),
603
+ ),
604
+ const SizedBox(width: 10),
605
+ Expanded(
606
+ child: Column(
607
+ crossAxisAlignment: CrossAxisAlignment.start,
608
+ children: <Widget>[
609
+ Text(info.label, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
610
+ Text(info.subtitle, style: const TextStyle(fontSize: 11, color: Colors.grey)),
611
+ ],
612
+ ),
613
+ ),
614
+ Container(
615
+ padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
616
+ decoration: BoxDecoration(
617
+ color: riskColor.withAlpha(20),
618
+ borderRadius: BorderRadius.circular(20),
619
+ ),
620
+ child: Text(
621
+ info.riskLevel.toUpperCase(),
622
+ style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: riskColor, letterSpacing: 0.5),
623
+ ),
624
+ ),
625
+ ],
626
+ ),
627
+ const SizedBox(height: 12),
628
+ SegmentedButton<String>(
629
+ segments: const <ButtonSegment<String>>[
630
+ ButtonSegment<String>(value: 'deny', label: Text('Block'), icon: Icon(Icons.block_rounded, size: 13)),
631
+ ButtonSegment<String>(value: 'require_approval', label: Text('Ask me'), icon: Icon(Icons.pan_tool_outlined, size: 13)),
632
+ ButtonSegment<String>(value: 'allow', label: Text('Allow'), icon: Icon(Icons.check_rounded, size: 13)),
633
+ ButtonSegment<String>(value: 'allow_always', label: Text('Always'), icon: Icon(Icons.verified_rounded, size: 13)),
634
+ ],
635
+ selected: <String>{policy},
636
+ onSelectionChanged: dimmed ? null : (s) => onChanged(s.first),
637
+ style: ButtonStyle(
638
+ visualDensity: VisualDensity.compact,
639
+ textStyle: WidgetStateProperty.all(const TextStyle(fontSize: 11)),
640
+ ),
641
+ ),
642
+ const SizedBox(height: 6),
643
+ _PolicyHint(policy: policy),
644
+ ],
645
+ ),
646
+ ),
647
+ ),
648
+ );
649
+ }
650
+ }
651
+
652
+ class _PolicyHint extends StatelessWidget {
653
+ const _PolicyHint({required this.policy});
654
+ final String policy;
655
+
656
+ @override
657
+ Widget build(BuildContext context) {
658
+ final (text, color) = switch (policy) {
659
+ 'deny' => ('Completely blocked — the agent cannot use this category.', Colors.red),
660
+ 'require_approval' => ('Agent pauses and asks you before running.', Colors.orange),
661
+ 'allow' => ('Allowed for this run — will ask again next session.', Colors.green),
662
+ 'allow_always' => ('Permanently allowed — never asks again.', Colors.blue),
663
+ _ => ('', Colors.grey),
664
+ };
665
+ if (text.isEmpty) return const SizedBox.shrink();
666
+ return Text(text, style: TextStyle(fontSize: 11, color: color));
667
+ }
668
+ }
669
+
670
+ // ── Tool approval bottom sheet ────────────────────────────────────────────────
671
+
672
+ class ToolApprovalSheet extends StatefulWidget {
673
+ const ToolApprovalSheet({
674
+ super.key,
675
+ required this.request,
676
+ required this.controller,
677
+ });
678
+ final ToolApprovalRequest request;
679
+ final NeoAgentController controller;
680
+
681
+ @override
682
+ State<ToolApprovalSheet> createState() => _ToolApprovalSheetState();
683
+ }
684
+
685
+ class _ToolApprovalSheetState extends State<ToolApprovalSheet>
686
+ with SingleTickerProviderStateMixin {
687
+ late final Timer _timer;
688
+ late final AnimationController _ringController;
689
+ int _remainingSeconds = 30;
690
+ bool _submitting = false;
691
+
692
+ @override
693
+ void initState() {
694
+ super.initState();
695
+ final diff = widget.request.expiresAt.difference(DateTime.now());
696
+ _remainingSeconds = diff.inSeconds.clamp(0, 30);
697
+
698
+ _ringController = AnimationController(
699
+ vsync: this,
700
+ duration: Duration(seconds: _remainingSeconds),
701
+ )..forward();
702
+
703
+ _timer = Timer.periodic(const Duration(seconds: 1), (_) {
704
+ if (!mounted) return;
705
+ setState(() => _remainingSeconds = (_remainingSeconds - 1).clamp(0, 30));
706
+ if (_remainingSeconds <= 0) {
707
+ _timer.cancel();
708
+ if (mounted) Navigator.of(context).pop();
709
+ }
710
+ });
711
+
712
+ // Cancel the notification now that the sheet is showing
713
+ _SecurityNotificationService.cancelApprovalNotification(widget.request.approvalId);
714
+ }
715
+
716
+ @override
717
+ void dispose() {
718
+ _timer.cancel();
719
+ _ringController.dispose();
720
+ super.dispose();
721
+ }
722
+
723
+ Future<void> _decide(String decision, String scope) async {
724
+ if (_submitting) return;
725
+ setState(() => _submitting = true);
726
+ _timer.cancel();
727
+ _ringController.stop();
728
+ try {
729
+ await widget.controller.backendClient.resolveToolApproval(
730
+ widget.controller.backendUrl,
731
+ approvalId: widget.request.approvalId,
732
+ decision: decision,
733
+ scope: scope,
734
+ runId: widget.request.runId,
735
+ toolName: widget.request.toolName,
736
+ toolArgs: widget.request.toolArgs,
737
+ );
738
+ widget.controller.clearPendingApproval();
739
+ } catch (_) {
740
+ // timeout will fire server-side; safe to dismiss
741
+ }
742
+ if (mounted) Navigator.of(context).pop();
743
+ }
744
+
745
+ String _formatArgs() {
746
+ final args = widget.request.toolArgs;
747
+ if (args.isEmpty) return '(no arguments)';
748
+ final buf = StringBuffer();
749
+ for (final e in args.entries) {
750
+ buf.writeln('${e.key}: ${_redact(e.key, e.value)}');
751
+ }
752
+ final out = buf.toString().trimRight();
753
+ return out.length > 500 ? '${out.substring(0, 500)}…' : out;
754
+ }
755
+
756
+ String _redact(String key, dynamic value) {
757
+ const sensitive = <String>['token', 'secret', 'password', 'key', 'api_key', 'auth', 'credential'];
758
+ if (sensitive.any((s) => key.toLowerCase().contains(s))) return '••••••';
759
+ return value?.toString() ?? 'null';
760
+ }
761
+
762
+ @override
763
+ Widget build(BuildContext context) {
764
+ final req = widget.request;
765
+ final info = _categoryInfo(req.category);
766
+ final colorScheme = Theme.of(context).colorScheme;
767
+ final urgent = _remainingSeconds <= 8;
768
+ final ringColor = urgent ? colorScheme.error : info.color;
769
+
770
+ return Padding(
771
+ padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
772
+ child: SafeArea(
773
+ child: Column(
774
+ mainAxisSize: MainAxisSize.min,
775
+ crossAxisAlignment: CrossAxisAlignment.stretch,
776
+ children: <Widget>[
777
+ // Handle bar
778
+ Center(
779
+ child: Container(
780
+ margin: const EdgeInsets.only(top: 10, bottom: 6),
781
+ width: 38, height: 4,
782
+ decoration: BoxDecoration(
783
+ color: colorScheme.outlineVariant,
784
+ borderRadius: BorderRadius.circular(2),
785
+ ),
786
+ ),
787
+ ),
788
+ // Header
789
+ Padding(
790
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
791
+ child: Row(
792
+ children: <Widget>[
793
+ // Countdown ring
794
+ SizedBox(
795
+ width: 52, height: 52,
796
+ child: Stack(
797
+ alignment: Alignment.center,
798
+ children: <Widget>[
799
+ AnimatedBuilder(
800
+ animation: _ringController,
801
+ builder: (_, __) => CircularProgressIndicator(
802
+ value: 1 - _ringController.value,
803
+ strokeWidth: 3.5,
804
+ backgroundColor: colorScheme.surfaceContainerHighest,
805
+ color: ringColor,
806
+ ),
807
+ ),
808
+ AnimatedDefaultTextStyle(
809
+ duration: const Duration(milliseconds: 200),
810
+ style: TextStyle(
811
+ fontSize: 14,
812
+ fontWeight: FontWeight.bold,
813
+ color: ringColor,
814
+ ),
815
+ child: Text('$_remainingSeconds'),
816
+ ),
817
+ ],
818
+ ),
819
+ ),
820
+ const SizedBox(width: 14),
821
+ Expanded(
822
+ child: Column(
823
+ crossAxisAlignment: CrossAxisAlignment.start,
824
+ children: <Widget>[
825
+ const Text('Approval required',
826
+ style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)),
827
+ const SizedBox(height: 4),
828
+ Row(
829
+ children: <Widget>[
830
+ Container(
831
+ padding: const EdgeInsets.all(4),
832
+ decoration: BoxDecoration(
833
+ color: info.color.withAlpha(22),
834
+ borderRadius: BorderRadius.circular(6),
835
+ ),
836
+ child: Icon(info.icon, size: 13, color: info.color),
837
+ ),
838
+ const SizedBox(width: 6),
839
+ Text(info.label,
840
+ style: TextStyle(
841
+ fontSize: 12,
842
+ color: info.color,
843
+ fontWeight: FontWeight.w600,
844
+ )),
845
+ ],
846
+ ),
847
+ ],
848
+ ),
849
+ ),
850
+ ],
851
+ ),
852
+ ),
853
+ const SizedBox(height: 14),
854
+ // Tool + args preview
855
+ Padding(
856
+ padding: const EdgeInsets.symmetric(horizontal: 20),
857
+ child: Container(
858
+ padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
859
+ decoration: BoxDecoration(
860
+ color: colorScheme.surfaceContainerHighest,
861
+ borderRadius: BorderRadius.circular(12),
862
+ border: Border.all(color: colorScheme.outlineVariant),
863
+ ),
864
+ child: Column(
865
+ crossAxisAlignment: CrossAxisAlignment.start,
866
+ children: <Widget>[
867
+ Row(
868
+ children: <Widget>[
869
+ Container(
870
+ width: 7, height: 7,
871
+ decoration: BoxDecoration(color: info.color, shape: BoxShape.circle),
872
+ ),
873
+ const SizedBox(width: 6),
874
+ Text(
875
+ req.toolName,
876
+ style: const TextStyle(
877
+ fontFamily: 'monospace',
878
+ fontWeight: FontWeight.w700,
879
+ fontSize: 13,
880
+ ),
881
+ ),
882
+ ],
883
+ ),
884
+ if (req.toolArgs.isNotEmpty) ...<Widget>[
885
+ const SizedBox(height: 8),
886
+ Text(
887
+ _formatArgs(),
888
+ style: TextStyle(
889
+ fontFamily: 'monospace',
890
+ fontSize: 11,
891
+ color: colorScheme.onSurfaceVariant,
892
+ height: 1.5,
893
+ ),
894
+ ),
895
+ ],
896
+ ],
897
+ ),
898
+ ),
899
+ ),
900
+ const SizedBox(height: 18),
901
+ // Action buttons
902
+ if (_submitting)
903
+ const Padding(
904
+ padding: EdgeInsets.only(bottom: 24),
905
+ child: Center(child: CircularProgressIndicator.adaptive()),
906
+ )
907
+ else
908
+ Padding(
909
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
910
+ child: Column(
911
+ children: <Widget>[
912
+ Row(
913
+ children: <Widget>[
914
+ Expanded(
915
+ child: OutlinedButton.icon(
916
+ icon: const Icon(Icons.block_rounded, size: 15),
917
+ label: const Text('Deny'),
918
+ style: OutlinedButton.styleFrom(
919
+ foregroundColor: colorScheme.error,
920
+ side: BorderSide(color: colorScheme.error.withAlpha(100)),
921
+ ),
922
+ onPressed: () => _decide('denied', 'once'),
923
+ ),
924
+ ),
925
+ const SizedBox(width: 8),
926
+ Expanded(
927
+ child: OutlinedButton.icon(
928
+ icon: const Icon(Icons.check_circle_outline, size: 15),
929
+ label: const Text('Allow once'),
930
+ onPressed: () => _decide('approved', 'once'),
931
+ ),
932
+ ),
933
+ ],
934
+ ),
935
+ const SizedBox(height: 8),
936
+ Row(
937
+ children: <Widget>[
938
+ Expanded(
939
+ child: OutlinedButton.icon(
940
+ icon: const Icon(Icons.history_rounded, size: 15),
941
+ label: const Text('Allow session'),
942
+ style: OutlinedButton.styleFrom(foregroundColor: Colors.teal),
943
+ onPressed: () => _decide('approved', 'session'),
944
+ ),
945
+ ),
946
+ const SizedBox(width: 8),
947
+ Expanded(
948
+ child: FilledButton.icon(
949
+ icon: const Icon(Icons.verified_rounded, size: 15),
950
+ label: const Text('Always allow'),
951
+ style: FilledButton.styleFrom(backgroundColor: info.color),
952
+ onPressed: () => _decide('approved', 'always'),
953
+ ),
954
+ ),
955
+ ],
956
+ ),
957
+ const SizedBox(height: 4),
958
+ Text(
959
+ '"Always allow" saves the policy permanently — you can change it in Settings.',
960
+ textAlign: TextAlign.center,
961
+ style: TextStyle(fontSize: 10, color: colorScheme.onSurfaceVariant),
962
+ ),
963
+ ],
964
+ ),
965
+ ),
966
+ ],
967
+ ),
968
+ ),
969
+ );
970
+ }
971
+ }