From 039f11b6bc06c5fc9e3e54f2471244efb3b5d3b2 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 17 Jul 2026 23:44:14 +0200 Subject: [PATCH] feat(store): trust gate before every module install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-click installs showed no trust signal at all (top security finding of the usertest panel). Every install path — store card, detail sheet, and the flow list's quick fix — now routes through one confirmation dialog showing what the hub actually knows before download: origin store, version, license, maturity, and required services/capabilities, plus the sandbox model and an honest note that per-entry signature status is not in the store index yet (verification happens hub-side at install). Widget tests cover content and confirm/cancel semantics. Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 12 ++ lib/l10n/app_en.arb | 12 ++ lib/l10n/app_localizations.dart | 66 ++++++++ lib/l10n/app_localizations_de.dart | 37 +++++ lib/l10n/app_localizations_en.dart | 37 +++++ lib/pages/flows.dart | 6 + lib/pages/store.dart | 10 ++ lib/widgets/chain_install_confirm.dart | 212 +++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + test/install_confirm_test.dart | 84 ++++++++++ 10 files changed, 477 insertions(+) create mode 100644 lib/widgets/chain_install_confirm.dart create mode 100644 test/install_confirm_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 21990d5..2627f1d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1565,6 +1565,18 @@ "aboutCopyTooltip": "Kopieren", "aboutCopiedToast": "In die Zwischenablage kopiert.", "sidebarSearchLabel": "Suchen & Befehle", + "installConfirmTitle": "„{name}\" installieren?", + "@installConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, + "installConfirmVersion": "Version", + "installConfirmSource": "Quelle", + "installConfirmSourceBundled": "Mitgelieferter Store-Index", + "installConfirmLicense": "Lizenz", + "installConfirmStatus": "Reifegrad", + "installConfirmNeedsServices": "Benötigte Dienste", + "installConfirmNeedsCapabilities": "Benötigte Fähigkeiten", + "installConfirmTrustTitle": "Vertrauen & Sicherheit", + "installConfirmTrustBody": "Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.", + "installConfirmSignatureNote": "Signaturen prüft der Hub beim Installieren, sofern die Signaturpflicht im Sicherheitsprofil aktiv ist. Einen Signaturstatus vorab zeigt der Store-Index derzeit noch nicht an (Alpha).", "settingsSidebarPinnedTitle": "Navigation immer ausgeklappt", "settingsSidebarPinnedBody": "Zeigt die Beschriftungen der Seitenleiste dauerhaft an, statt sie nur beim Überfahren mit der Maus einzublenden.", "settingsCategoryAi": "System-KI", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 106f3db..a0d7111 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1589,6 +1589,18 @@ "aboutCopyTooltip": "Copy", "aboutCopiedToast": "Copied to the clipboard.", "sidebarSearchLabel": "Search & commands", + "installConfirmTitle": "Install \"{name}\"?", + "@installConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, + "installConfirmVersion": "Version", + "installConfirmSource": "Source", + "installConfirmSourceBundled": "Bundled store index", + "installConfirmLicense": "License", + "installConfirmStatus": "Maturity", + "installConfirmNeedsServices": "Required services", + "installConfirmNeedsCapabilities": "Required capabilities", + "installConfirmTrustTitle": "Trust & security", + "installConfirmTrustBody": "The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.", + "installConfirmSignatureNote": "The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).", "settingsSidebarPinnedTitle": "Keep the navigation expanded", "settingsSidebarPinnedBody": "Shows the sidebar labels permanently instead of only while hovering with the mouse.", "settingsCategoryAi": "System AI", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index eca27bd..5d897e0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4640,6 +4640,72 @@ abstract class AppLocalizations { /// **'Search & commands'** String get sidebarSearchLabel; + /// No description provided for @installConfirmTitle. + /// + /// In en, this message translates to: + /// **'Install \"{name}\"?'** + String installConfirmTitle(String name); + + /// No description provided for @installConfirmVersion. + /// + /// In en, this message translates to: + /// **'Version'** + String get installConfirmVersion; + + /// No description provided for @installConfirmSource. + /// + /// In en, this message translates to: + /// **'Source'** + String get installConfirmSource; + + /// No description provided for @installConfirmSourceBundled. + /// + /// In en, this message translates to: + /// **'Bundled store index'** + String get installConfirmSourceBundled; + + /// No description provided for @installConfirmLicense. + /// + /// In en, this message translates to: + /// **'License'** + String get installConfirmLicense; + + /// No description provided for @installConfirmStatus. + /// + /// In en, this message translates to: + /// **'Maturity'** + String get installConfirmStatus; + + /// No description provided for @installConfirmNeedsServices. + /// + /// In en, this message translates to: + /// **'Required services'** + String get installConfirmNeedsServices; + + /// No description provided for @installConfirmNeedsCapabilities. + /// + /// In en, this message translates to: + /// **'Required capabilities'** + String get installConfirmNeedsCapabilities; + + /// No description provided for @installConfirmTrustTitle. + /// + /// In en, this message translates to: + /// **'Trust & security'** + String get installConfirmTrustTitle; + + /// No description provided for @installConfirmTrustBody. + /// + /// In en, this message translates to: + /// **'The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.'** + String get installConfirmTrustBody; + + /// No description provided for @installConfirmSignatureNote. + /// + /// In en, this message translates to: + /// **'The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).'** + String get installConfirmSignatureNote; + /// No description provided for @settingsSidebarPinnedTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 58a845d..11f62e4 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2715,6 +2715,43 @@ class AppLocalizationsDe extends AppLocalizations { @override String get sidebarSearchLabel => 'Suchen & Befehle'; + @override + String installConfirmTitle(String name) { + return '„$name\" installieren?'; + } + + @override + String get installConfirmVersion => 'Version'; + + @override + String get installConfirmSource => 'Quelle'; + + @override + String get installConfirmSourceBundled => 'Mitgelieferter Store-Index'; + + @override + String get installConfirmLicense => 'Lizenz'; + + @override + String get installConfirmStatus => 'Reifegrad'; + + @override + String get installConfirmNeedsServices => 'Benötigte Dienste'; + + @override + String get installConfirmNeedsCapabilities => 'Benötigte Fähigkeiten'; + + @override + String get installConfirmTrustTitle => 'Vertrauen & Sicherheit'; + + @override + String get installConfirmTrustBody => + 'Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.'; + + @override + String get installConfirmSignatureNote => + 'Signaturen prüft der Hub beim Installieren, sofern die Signaturpflicht im Sicherheitsprofil aktiv ist. Einen Signaturstatus vorab zeigt der Store-Index derzeit noch nicht an (Alpha).'; + @override String get settingsSidebarPinnedTitle => 'Navigation immer ausgeklappt'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 84ff859..4bb85ab 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2717,6 +2717,43 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sidebarSearchLabel => 'Search & commands'; + @override + String installConfirmTitle(String name) { + return 'Install \"$name\"?'; + } + + @override + String get installConfirmVersion => 'Version'; + + @override + String get installConfirmSource => 'Source'; + + @override + String get installConfirmSourceBundled => 'Bundled store index'; + + @override + String get installConfirmLicense => 'License'; + + @override + String get installConfirmStatus => 'Maturity'; + + @override + String get installConfirmNeedsServices => 'Required services'; + + @override + String get installConfirmNeedsCapabilities => 'Required capabilities'; + + @override + String get installConfirmTrustTitle => 'Trust & security'; + + @override + String get installConfirmTrustBody => + 'The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.'; + + @override + String get installConfirmSignatureNote => + 'The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).'; + @override String get settingsSidebarPinnedTitle => 'Keep the navigation expanded'; diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index f1bd9c4..b5b8b55 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -19,6 +19,7 @@ import '../data/flow_run_driver.dart'; import '../data/hub.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; +import '../widgets/chain_install_confirm.dart'; class FlowsPage extends StatefulWidget { /// Pre-load this flow when the editor first builds. Studio @@ -114,6 +115,11 @@ class _FlowsPageState extends State { // entry for 'debug.echo@^0'"). Strip it — same as the Store page. final at = capability.indexOf('@'); final bare = at < 0 ? capability : capability.substring(0, at); + // The quick-fix install goes through the same trust dialog as + // the Store page — no quieter direct path. + final confirmed = + await ChainInstallConfirmDialog.showForCapability(context, bare); + if (!confirmed) return null; return _runInstall(source: bare); } diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 507e126..6218953 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -193,6 +193,11 @@ class _StorePageState extends State { Future _install(StoreItem item) async { if (!mounted) return; + // Trust gate: provenance + sandbox/signature context BEFORE + // anything is downloaded (usertest: one-click install with + // no visible trust signal was the top security finding). + final confirmed = await ChainInstallConfirmDialog.show(context, item); + if (!confirmed || !mounted) return; final outcome = await showDialog( context: context, barrierDismissible: false, @@ -2293,6 +2298,11 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { } Future _install() async { + // Same trust gate as the store card — the detail sheet's + // install button must not be a quieter bypass. + final confirmed = + await ChainInstallConfirmDialog.show(context, widget.item); + if (!confirmed || !mounted) return; setState(() { _busy = true; _toast = null; diff --git a/lib/widgets/chain_install_confirm.dart b/lib/widgets/chain_install_confirm.dart new file mode 100644 index 0000000..b2c33e7 --- /dev/null +++ b/lib/widgets/chain_install_confirm.dart @@ -0,0 +1,212 @@ +// Install confirmation with provenance + trust context. The +// usertest security persona flagged one-click installs with no +// visible trust signal as the platform's biggest supply-chain +// gap: every install path (store card, detail sheet, flow-list +// quick fix) now routes through this dialog, which shows what +// the hub actually knows before anything is downloaded — origin +// store, version, license, maturity, required services — plus +// an honest note on when signature verification happens. No +// fake "verified" badge: per-entry signature status is not in +// the store index yet, and the dialog says so instead of +// pretending. + +import 'package:flutter/material.dart'; + +import '../data/hub.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; + +class ChainInstallConfirmDialog extends StatelessWidget { + final StoreItem item; + + const ChainInstallConfirmDialog({super.key, required this.item}); + + /// Confirm installing a known store entry. Returns true to + /// proceed. + static Future show(BuildContext context, StoreItem item) async { + final r = await showDialog( + context: context, + builder: (_) => ChainInstallConfirmDialog(item: item), + ); + return r == true; + } + + /// Confirm installing by bare capability name (the flow-list + /// quick-fix path). Looks the entry up in the store index so + /// the dialog shows the same provenance as the Store page; + /// falls back to a minimal entry when the index has no match + /// (the hub will reject unknown names on install anyway). + static Future showForCapability( + BuildContext context, + String capability, + ) async { + StoreItem? match; + try { + final hits = await HubService.instance.searchStore( + query: capability, + limit: 10, + ); + for (final h in hits) { + if (h.name == capability) { + match = h; + break; + } + } + } catch (_) { + // Unreachable store index — fall through to the minimal + // entry; the dialog still explains the trust model. + } + if (!context.mounted) return false; + return show( + context, + match ?? + StoreItem( + name: capability, + taglineEn: '', + taglineDe: '', + descriptionEn: '', + descriptionDe: '', + category: '', + tags: const [], + requiresCapabilities: const [], + requiresServices: const [], + license: '', + repository: '', + bestVersion: '', + status: '', + installed: false, + featured: false, + iconUrl: '', + screenshotUrls: const [], + docsUrl: '', + kind: '', + provider: '', + sourceKind: '', + source: '', + canonicalCategory: '', + canonicalCategoryLabel: '', + ), + ); + } + + String _statusLabel(AppLocalizations l) => switch (item.status) { + 'published' => l.storeStatusPublished, + 'alpha' => l.storeStatusAlpha, + 'planned' => l.storeStatusPlanned, + _ => item.status, + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final rows = <(String, String)>[ + if (item.bestVersion.isNotEmpty) + (l.installConfirmVersion, 'v${item.bestVersion}'), + ( + l.installConfirmSource, + item.source.isEmpty || item.source == 'bundled' + ? l.installConfirmSourceBundled + : item.source, + ), + if (item.license.isNotEmpty) (l.installConfirmLicense, item.license), + if (item.status.isNotEmpty) (l.installConfirmStatus, _statusLabel(l)), + if (item.requiresServices.isNotEmpty) + (l.installConfirmNeedsServices, item.requiresServices.join(', ')), + if (item.requiresCapabilities.isNotEmpty) + ( + l.installConfirmNeedsCapabilities, + item.requiresCapabilities.join(', ') + ), + ]; + return AlertDialog( + title: Text(l.installConfirmTitle(item.name)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ChainRadius.md), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (label, value) in rows) + Padding( + padding: const EdgeInsets.only(bottom: ChainSpace.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text(value, style: theme.textTheme.bodySmall), + ), + ], + ), + ), + const SizedBox(height: ChainSpace.sm), + Container( + padding: const EdgeInsets.all(ChainSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(ChainRadius.sm), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.shield_outlined, + size: 14, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + l.installConfirmTrustTitle, + style: theme.textTheme.labelMedium, + ), + ], + ), + const SizedBox(height: ChainSpace.xs), + Text( + l.installConfirmTrustBody, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: ChainSpace.xs), + Text( + l.installConfirmSignatureNote, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.buttonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l.buttonInstall), + ), + ], + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index e58eca5..640271e 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -12,6 +12,7 @@ export 'chain_empty_state.dart'; export 'chain_en_badge.dart'; export 'chain_error_box.dart'; export 'chain_flow_output.dart'; +export 'chain_install_confirm.dart'; export 'chain_log_viewer.dart'; export 'chain_module_sheet.dart'; export 'chain_pill.dart'; diff --git a/test/install_confirm_test.dart b/test/install_confirm_test.dart new file mode 100644 index 0000000..7bcdb31 --- /dev/null +++ b/test/install_confirm_test.dart @@ -0,0 +1,84 @@ +// Install trust gate — every install path routes through this +// dialog, so it must show provenance (source, version, license, +// maturity) and the honest signature note, and only proceed on +// an explicit confirmation. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/widgets/chain_install_confirm.dart'; + +const _item = StoreItem( + name: 'text.extract', + taglineEn: '', + taglineDe: '', + descriptionEn: '', + descriptionDe: '', + category: 'text', + tags: [], + requiresCapabilities: [], + requiresServices: ['judge-ner'], + license: 'Apache-2.0', + repository: '', + bestVersion: '0.1.0', + status: 'alpha', + installed: false, + featured: false, + iconUrl: '', + screenshotUrls: [], + docsUrl: '', + kind: 'native', + provider: 'fai', + source: 'bundled', +); + +Widget _host({required void Function(bool) onResult}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + onResult(await ChainInstallConfirmDialog.show(context, _item)); + }, + child: const Text('go'), + ), + ), + ), + ); + +void main() { + testWidgets('shows provenance, maturity, and the signature note', + (tester) async { + await tester.pumpWidget(_host(onResult: (_) {})); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + + expect(find.text('„text.extract" installieren?'), findsOneWidget); + expect(find.text('v0.1.0'), findsOneWidget); + expect(find.text('Mitgelieferter Store-Index'), findsOneWidget); + expect(find.text('Apache-2.0'), findsOneWidget); + expect(find.text('experimentell'), findsOneWidget); + expect(find.text('judge-ner'), findsOneWidget); + expect(find.textContaining('Signaturen prüft der Hub'), findsOneWidget); + }); + + testWidgets('cancel returns false, install returns true', (tester) async { + bool? result; + await tester.pumpWidget(_host(onResult: (r) => result = r)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Abbrechen')); + await tester.pumpAndSettle(); + expect(result, isFalse); + + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Installieren')); + await tester.pumpAndSettle(); + expect(result, isTrue); + }); +}