diff --git a/docs/security.md b/docs/security.md index 580174e1..32f46518 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,3 +37,16 @@ Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app u Archives exceeding these bounds fail closed before files are written to disk. Path traversal checks remain in `archive_path_guard.dart`. +## Extension driver OS sandbox + +Process-sandbox database drivers launch inside OS-level isolation when available: + +| Platform | Wrapper | When unavailable | +|----------|---------|------------------| +| Linux | `bwrap` (bubblewrap) | User must confirm **Run without OS sandbox** | +| macOS | `sandbox-exec` (Seatbelt) | N/A — always wrapped | +| Windows | AppContainer (planned) | User must confirm until native helper ships | + +Querya refuses **silent** unsandboxed launch. `SandboxProcessRunner` throws `SandboxOsIsolationUnavailableException` until the user approves via the consent dialog registered from the main window. + +**Linux:** install `bubblewrap` and ensure unprivileged user namespaces are enabled if you want OS sandbox without manual confirmation. \ No newline at end of file diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index 31f46f13..ee8a3d7a 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -11,6 +11,8 @@ import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_object_metadata.dart'; import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; +import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -124,18 +126,52 @@ class ExtensionDriverSession { } final bridge = bridgeFactory?.call() ?? PluginRpcBridge(); - await bridge.start( + await _startBridgeWithConsent( + bridge: bridge, manifest: manifest, pluginExecutable: executable, extensionRoot: root, - handshakeParams: { - 'queryaVersion': '2.0.0', - 'pluginId': manifest.id, - }, ); return bridge; } + Future _startBridgeWithConsent({ + required PluginRpcBridge bridge, + required ExtensionManifest manifest, + required String pluginExecutable, + required String extensionRoot, + }) async { + const handshakeParams = { + 'queryaVersion': '2.0.0', + }; + + try { + await bridge.start( + manifest: manifest, + pluginExecutable: pluginExecutable, + extensionRoot: extensionRoot, + handshakeParams: { + ...handshakeParams, + 'pluginId': manifest.id, + }, + ); + } on SandboxOsIsolationUnavailableException catch (error) { + final approved = + await UnsandboxedLaunchConsentGate.instance.request(error); + if (!approved) rethrow; + await bridge.start( + manifest: manifest, + pluginExecutable: pluginExecutable, + extensionRoot: extensionRoot, + allowUnsandboxedLaunch: true, + handshakeParams: { + ...handshakeParams, + 'pluginId': manifest.id, + }, + ); + } + } + Future _injectAndConnect( PluginRpcBridge bridge, { required int connectionId, diff --git a/lib/core/extensions/rpc/plugin_rpc_bridge.dart b/lib/core/extensions/rpc/plugin_rpc_bridge.dart index 89153785..9e528bb3 100644 --- a/lib/core/extensions/rpc/plugin_rpc_bridge.dart +++ b/lib/core/extensions/rpc/plugin_rpc_bridge.dart @@ -62,6 +62,7 @@ class PluginRpcBridge { String? extensionRoot, Map? environment, Map? handshakeParams, + bool allowUnsandboxedLaunch = false, }) async { if (_started) { throw StateError('PluginRpcBridge already started'); @@ -77,6 +78,7 @@ class PluginRpcBridge { extensionRoot: extensionRoot ?? manifest.installPath, capabilities: capabilities, environment: environment, + allowUnsandboxedLaunch: allowUnsandboxedLaunch, ); _handle = handle; diff --git a/lib/core/extensions/sandbox/sandbox_os_isolation.dart b/lib/core/extensions/sandbox/sandbox_os_isolation.dart new file mode 100644 index 00000000..53d381ee --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_os_isolation.dart @@ -0,0 +1,58 @@ +import 'sandbox_launch_command.dart'; + +/// Thrown when a process-sandbox driver would launch without OS-level isolation. +class SandboxOsIsolationUnavailableException implements Exception { + const SandboxOsIsolationUnavailableException({ + required this.platform, + required this.message, + this.installHint, + }); + + final String platform; + final String message; + final String? installHint; + + @override + String toString() => 'SandboxOsIsolationUnavailableException: $message'; +} + +/// Describes why OS sandboxing is unavailable for a launch command. +abstract final class SandboxOsIsolation { + static SandboxOsIsolationUnavailableException? exceptionForLaunchCommand( + SandboxLaunchCommand command, + ) { + if (command.usesOsSandbox) return null; + + switch (command.platform) { + case 'linux': + return const SandboxOsIsolationUnavailableException( + platform: 'linux', + message: + 'OS sandbox (bubblewrap) is not available on this Linux system.', + installHint: + 'Install bubblewrap (bwrap) from your distribution and ensure ' + 'unprivileged user namespaces are enabled, or confirm below to ' + 'run the driver without OS sandbox.', + ); + case 'windows': + return const SandboxOsIsolationUnavailableException( + platform: 'windows', + message: + 'Native OS sandbox is not yet available for extension drivers ' + 'on Windows.', + installHint: + 'Drivers run with soft isolation only until AppContainer support ' + 'lands. Confirm below only if you trust this extension.', + ); + default: + return SandboxOsIsolationUnavailableException( + platform: command.platform, + message: + 'OS sandbox is not available for extension drivers on ' + '${command.platform}.', + installHint: + 'Confirm below only if you trust this extension package.', + ); + } + } +} diff --git a/lib/core/extensions/sandbox/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart index 09d86146..e793eac1 100644 --- a/lib/core/extensions/sandbox/sandbox_process_runner.dart +++ b/lib/core/extensions/sandbox/sandbox_process_runner.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart'; @@ -100,6 +101,9 @@ class SandboxProcessRunner { /// Spawns [pluginExecutable] inside the OS sandbox for [pluginId]. /// + /// When OS sandboxing is unavailable, launch fails unless + /// [allowUnsandboxedLaunch] is true (requires explicit user consent in UI). + /// /// Credentials must never be passed via [pluginArguments] or [environment]; /// use [SandboxCredentialsInjector] over Stdio JSON-RPC instead. Future start({ @@ -109,6 +113,7 @@ class SandboxProcessRunner { String? extensionRoot, SandboxCapabilities? capabilities, Map? environment, + bool allowUnsandboxedLaunch = false, }) async { SandboxSecretGuard.assertNoSecrets( arguments: pluginArguments, @@ -125,7 +130,8 @@ class SandboxProcessRunner { if (bwrapAvailable == null && !usesBwrap) { debugPrint( 'SandboxProcessRunner: bubblewrap unavailable or cannot set up user ' - 'namespaces on this system; launching $pluginId without OS sandbox.', + 'namespaces on this system; $pluginId requires consent to launch ' + 'without OS sandbox.', ); } final command = SandboxLaunchCommand.build( @@ -138,6 +144,20 @@ class SandboxProcessRunner { bwrapAvailable: usesBwrap, ); + final isolationIssue = + SandboxOsIsolation.exceptionForLaunchCommand(command); + if (isolationIssue != null && !allowUnsandboxedLaunch) { + await scratch.delete(); + throw isolationIssue; + } + + if (isolationIssue != null) { + debugPrint( + 'SandboxProcessRunner: launching $pluginId without OS sandbox after ' + 'explicit consent (${command.platform}).', + ); + } + // Never forward parent secrets via environment. Only pass an explicit map // (credentials go through Stdio JSON-RPC — Block E §5). final sanitizedEnv = { diff --git a/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart b/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart new file mode 100644 index 00000000..4bd2df05 --- /dev/null +++ b/lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart @@ -0,0 +1,21 @@ +import 'sandbox_os_isolation.dart'; + +typedef UnsandboxedLaunchConsentHandler = Future Function( + SandboxOsIsolationUnavailableException details, +); + +/// App-level hook for explicit user consent before unsandboxed driver launch. +class UnsandboxedLaunchConsentGate { + UnsandboxedLaunchConsentGate._(); + + static final UnsandboxedLaunchConsentGate instance = + UnsandboxedLaunchConsentGate._(); + + UnsandboxedLaunchConsentHandler? handler; + + Future request(SandboxOsIsolationUnavailableException details) async { + final callback = handler; + if (callback == null) return false; + return callback(details); + } +} diff --git a/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart b/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart new file mode 100644 index 00000000..dbba764d --- /dev/null +++ b/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +Future showUnsandboxedDriverConsentDialog( + material.BuildContext context, + SandboxOsIsolationUnavailableException details, +) async { + final approved = await showAppDialog( + context: context, + builder: (dialogContext) => material.AlertDialog( + title: const material.Text('Run driver without OS sandbox?'), + content: material.SizedBox( + width: 440, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text(details.message), + if (details.installHint != null) ...[ + const material.SizedBox(height: 12), + material.Text( + details.installHint!, + style: material.TextStyle( + fontSize: 13, + color: Theme.of(dialogContext).colorScheme.mutedForeground, + ), + ), + ], + const material.SizedBox(height: 12), + const material.Text( + 'The driver process may access your user session (files, network) ' + 'beyond the extension manifest policy. Only continue if you trust ' + 'this extension package.', + ), + ], + ), + ), + actions: [ + OutlineButton( + onPressed: () => material.Navigator.pop(dialogContext, false), + child: const material.Text('Cancel'), + ), + PrimaryButton( + onPressed: () => material.Navigator.pop(dialogContext, true), + child: const material.Text('Run without OS sandbox'), + ), + ], + ), + ); + return approved == true; +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 307fd937..f24f201e 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -13,6 +13,8 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; +import 'package:querya_desktop/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; @@ -39,8 +41,18 @@ class _MainScreenState extends State { final ValueNotifier _workspace = ValueNotifier(MainScreenWorkspaceState.empty); + @override + void initState() { + super.initState(); + UnsandboxedLaunchConsentGate.instance.handler = (details) { + if (!mounted) return Future.value(false); + return showUnsandboxedDriverConsentDialog(context, details); + }; + } + @override void dispose() { + UnsandboxedLaunchConsentGate.instance.handler = null; _workspace.dispose(); super.dispose(); } diff --git a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart index 6d5f73e9..c7bee5e8 100644 --- a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart +++ b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart @@ -150,6 +150,7 @@ void main() { final handshake = await bridge.start( manifest: testManifest, pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, ); expect(handshake, isA()); expect((handshake as Map)['protocolVersion'], '1.0'); @@ -190,7 +191,11 @@ void main() { ); await expectLater( - bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'), + bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ), throwsA(isA()), ); expect(bridge.isStarted, isFalse); @@ -234,7 +239,11 @@ void main() { requestTimeout: const Duration(seconds: 5), ); - await bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'); + await bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ); final pending = bridge.connect({'host': 'x'}); await Future.delayed(const Duration(milliseconds: 20)); process.completeExit(1); @@ -296,7 +305,11 @@ void main() { enableStderrPipe: false, ); - await bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'); + await bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + allowUnsandboxedLaunch: true, + ); await bridge.shutdown(); expect(shutdownReceived, isTrue); diff --git a/test/core/extensions/sandbox/sandbox_os_isolation_test.dart b/test/core/extensions/sandbox/sandbox_os_isolation_test.dart new file mode 100644 index 00000000..b0c7f401 --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_os_isolation_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; + +void main() { + group('SandboxOsIsolation', () { + test('returns null when launch command uses OS sandbox', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: '/bin/driver', + scratchPath: '/tmp/s', + platformOverride: 'linux', + bwrapAvailable: true, + ); + + expect( + SandboxOsIsolation.exceptionForLaunchCommand(command), + isNull, + ); + }); + + test('returns linux message when bwrap unavailable', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: '/bin/driver', + scratchPath: '/tmp/s', + platformOverride: 'linux', + bwrapAvailable: false, + ); + + final error = SandboxOsIsolation.exceptionForLaunchCommand(command); + expect(error, isNotNull); + expect(error!.platform, 'linux'); + expect(error.message, contains('bubblewrap')); + }); + + test('returns windows message for soft isolation path', () { + final command = SandboxLaunchCommand.build( + pluginExecutable: r'C:\driver.exe', + scratchPath: r'C:\tmp\s', + platformOverride: 'windows', + ); + + final error = SandboxOsIsolation.exceptionForLaunchCommand(command); + expect(error, isNotNull); + expect(error!.platform, 'windows'); + expect(error.message, contains('Windows')); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_process_runner_test.dart b/test/core/extensions/sandbox/sandbox_process_runner_test.dart index b2211b71..3b94223c 100644 --- a/test/core/extensions/sandbox/sandbox_process_runner_test.dart +++ b/test/core/extensions/sandbox/sandbox_process_runner_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; @@ -259,6 +260,58 @@ void main() { await handle.dispose(); }); + test('refuses unsandboxed launch without explicit consent', () async { + final runner = SandboxProcessRunner( + platformOverride: 'linux', + bwrapAvailable: false, + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + _FakeProcess(), + ); + + await expectLater( + () => runner.start( + pluginId: 'test.driver', + pluginExecutable: '/bin/driver', + ), + throwsA(isA()), + ); + }); + + test('allows unsandboxed launch when consent flag is set', () async { + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + _FakeProcess(), + ); + + final handle = await runner.start( + pluginId: 'win.driver', + pluginExecutable: 'driver.exe', + allowUnsandboxedLaunch: true, + ); + + expect(handle.launchCommand.usesOsSandbox, isFalse); + await handle.dispose(); + }); + test('start deletes scratch when process spawn fails', () async { Directory? observedScratch; final runner = SandboxProcessRunner( @@ -282,6 +335,7 @@ void main() { () => runner.start( pluginId: 'fail.driver', pluginExecutable: 'driver.exe', + allowUnsandboxedLaunch: true, ), throwsA(isA()), );