Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
46 changes: 41 additions & 5 deletions lib/core/extensions/extension_driver_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> _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<Object?> _injectAndConnect(
PluginRpcBridge bridge, {
required int connectionId,
Expand Down
2 changes: 2 additions & 0 deletions lib/core/extensions/rpc/plugin_rpc_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class PluginRpcBridge {
String? extensionRoot,
Map<String, String>? environment,
Map<String, Object?>? handshakeParams,
bool allowUnsandboxedLaunch = false,
}) async {
if (_started) {
throw StateError('PluginRpcBridge already started');
Expand All @@ -77,6 +78,7 @@ class PluginRpcBridge {
extensionRoot: extensionRoot ?? manifest.installPath,
capabilities: capabilities,
environment: environment,
allowUnsandboxedLaunch: allowUnsandboxedLaunch,
);

_handle = handle;
Expand Down
58 changes: 58 additions & 0 deletions lib/core/extensions/sandbox/sandbox_os_isolation.dart
Original file line number Diff line number Diff line change
@@ -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.',
);
}
}
}
22 changes: 21 additions & 1 deletion lib/core/extensions/sandbox/sandbox_process_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<SandboxProcessHandle> start({
Expand All @@ -109,6 +113,7 @@ class SandboxProcessRunner {
String? extensionRoot,
SandboxCapabilities? capabilities,
Map<String, String>? environment,
bool allowUnsandboxedLaunch = false,
}) async {
SandboxSecretGuard.assertNoSecrets(
arguments: pluginArguments,
Expand All @@ -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(
Expand All @@ -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 = <String, String>{
Expand Down
21 changes: 21 additions & 0 deletions lib/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'sandbox_os_isolation.dart';

typedef UnsandboxedLaunchConsentHandler = Future<bool> 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<bool> request(SandboxOsIsolationUnavailableException details) async {
final callback = handler;
if (callback == null) return false;
return callback(details);
}
}
Original file line number Diff line number Diff line change
@@ -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<bool> showUnsandboxedDriverConsentDialog(
material.BuildContext context,
SandboxOsIsolationUnavailableException details,
) async {
final approved = await showAppDialog<bool>(
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;
}
12 changes: 12 additions & 0 deletions lib/features/main_screen/main_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -39,8 +41,18 @@ class _MainScreenState extends State<MainScreen> {
final ValueNotifier<MainScreenWorkspaceState> _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();
}
Expand Down
19 changes: 16 additions & 3 deletions test/core/extensions/rpc/plugin_rpc_bridge_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ void main() {
final handshake = await bridge.start(
manifest: testManifest,
pluginExecutable: '/opt/driver',
allowUnsandboxedLaunch: true,
);
expect(handshake, isA<Map>());
expect((handshake as Map)['protocolVersion'], '1.0');
Expand Down Expand Up @@ -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<PluginProtocolTimeoutException>()),
);
expect(bridge.isStarted, isFalse);
Expand Down Expand Up @@ -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<void>.delayed(const Duration(milliseconds: 20));
process.completeExit(1);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading