feat(store): trust gate before every module install
Some checks failed
Security / Security check (push) Failing after 2s

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-17 23:44:14 +02:00
parent c253f5d23e
commit 039f11b6bc
10 changed files with 477 additions and 0 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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<FlowsPage> {
// 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);
}

View file

@ -193,6 +193,11 @@ class _StorePageState extends State<StorePage> {
Future<void> _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<bool>(
context: context,
barrierDismissible: false,
@ -2293,6 +2298,11 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
}
Future<void> _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;

View file

@ -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<bool> show(BuildContext context, StoreItem item) async {
final r = await showDialog<bool>(
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<bool> 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),
),
],
);
}
}

View file

@ -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';

View file

@ -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);
});
}