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
175 changes: 175 additions & 0 deletions lib/core/extensions/sandbox/sandbox_launch_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import 'dart:io';

import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart';

/// Resolved argv for launching a plugin inside the OS process sandbox.
class SandboxLaunchCommand {
const SandboxLaunchCommand({
required this.executable,
required this.arguments,
required this.platform,
this.usesOsSandbox = true,
});

/// Outer executable (`bwrap`, `sandbox-exec`, or the plugin binary itself).
final String executable;

/// Full argument list passed to [executable].
final List<String> arguments;

/// Platform this command was built for (`linux`, `macos`, `windows`, …).
final String platform;

/// Whether an OS-level sandbox wrapper is applied.
final bool usesOsSandbox;

/// Builds a platform-specific launch command.
///
/// - **Linux:** `bwrap --unshare-all --share-net … -- <plugin> <args>`
/// - **macOS:** `sandbox-exec -p <profile> <plugin> <args>`
/// - **Windows:** direct process start (AppContainer/Job Object applied by
/// the runner after spawn; see [SandboxProcessRunner]).
factory SandboxLaunchCommand.build({
required String pluginExecutable,
List<String> pluginArguments = const [],
required String scratchPath,
String? extensionRoot,
SandboxCapabilities? capabilities,
String? platformOverride,
bool bwrapAvailable = true,
}) {
final platform = platformOverride ?? _currentPlatform;
switch (platform) {
case 'linux':
return _linux(
pluginExecutable: pluginExecutable,
pluginArguments: pluginArguments,
scratchPath: scratchPath,
extensionRoot: extensionRoot,
capabilities: capabilities,
bwrapAvailable: bwrapAvailable,
);
case 'macos':
return _macos(
pluginExecutable: pluginExecutable,
pluginArguments: pluginArguments,
scratchPath: scratchPath,
extensionRoot: extensionRoot,
);
case 'windows':
return SandboxLaunchCommand(
executable: pluginExecutable,
arguments: List<String>.from(pluginArguments),
platform: 'windows',
// Soft isolation until native AppContainer helper lands.
usesOsSandbox: false,
);
default:
return SandboxLaunchCommand(
executable: pluginExecutable,
arguments: List<String>.from(pluginArguments),
platform: platform,
usesOsSandbox: false,
);
}
}

static String get _currentPlatform {
if (Platform.isLinux) return 'linux';
if (Platform.isMacOS) return 'macos';
if (Platform.isWindows) return 'windows';
return Platform.operatingSystem;
}

static SandboxLaunchCommand _linux({
required String pluginExecutable,
required List<String> pluginArguments,
required String scratchPath,
String? extensionRoot,
SandboxCapabilities? capabilities,
required bool bwrapAvailable,
}) {
if (!bwrapAvailable) {
return SandboxLaunchCommand(
executable: pluginExecutable,
arguments: List<String>.from(pluginArguments),
platform: 'linux',
usesOsSandbox: false,
);
}

final args = <String>[
// Isolate all namespaces except network (DB drivers need TCP/TLS).
'--unshare-all',
'--share-net',
'--die-with-parent',
'--new-session',
// Root filesystem read-only; scratch and (optional) extension root RW/RO.
'--ro-bind', '/', '/',
'--bind', scratchPath, scratchPath,
'--chdir', scratchPath,
];

if (extensionRoot != null && extensionRoot.isNotEmpty) {
args.addAll(['--ro-bind', extensionRoot, extensionRoot]);
}

final maxOpenFiles =
capabilities?.resources.maxOpenFiles ?? ResourceLimits.defaultMaxOpenFiles;
// Soft hint via environment; hard ulimit applied by the runner when possible.
args.addAll(['--setenv', 'QUERYA_SANDBOX_MAX_OPEN_FILES', '$maxOpenFiles']);
args.addAll(['--setenv', 'QUERYA_SANDBOX_SCRATCH', scratchPath]);

args.add('--');
args.add(pluginExecutable);
args.addAll(pluginArguments);

return SandboxLaunchCommand(
executable: 'bwrap',
arguments: args,
platform: 'linux',
);
}

static SandboxLaunchCommand _macos({
required String pluginExecutable,
required List<String> pluginArguments,
required String scratchPath,
String? extensionRoot,
}) {
final profile = buildMacOsSeatbeltProfile(
scratchPath: scratchPath,
extensionRoot: extensionRoot,
);
return SandboxLaunchCommand(
executable: 'sandbox-exec',
arguments: [
'-p',
profile,
pluginExecutable,
...pluginArguments,
],
platform: 'macos',
);
}
}

/// Seatbelt (sandbox-exec) profile allowing network + scratch RW only.
String buildMacOsSeatbeltProfile({
required String scratchPath,
String? extensionRoot,
}) {
final buffer = StringBuffer()
..writeln('(version 1)')
..writeln('(deny default)')
..writeln('(allow process*)')
..writeln('(allow sysctl-read)')
..writeln('(allow mach-lookup)')
..writeln('(allow network*)')
..writeln('(allow file-read*)')
..writeln('(allow file-write* (subpath "$scratchPath"))');
if (extensionRoot != null && extensionRoot.isNotEmpty) {
buffer.writeln('(allow file-read* (subpath "$extensionRoot"))');
}
return buffer.toString().trimRight();
}
173 changes: 173 additions & 0 deletions lib/core/extensions/sandbox/sandbox_process_runner.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import 'dart:async';
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_scratch_directory.dart';

/// Live handle for a sandboxed OS process (Block E Level 2).
class SandboxProcessHandle {
SandboxProcessHandle({
required this.pluginId,
required this.process,
required this.scratch,
required this.launchCommand,
});

final String pluginId;
final Process process;
final SandboxScratchDirectory scratch;
final SandboxLaunchCommand launchCommand;

bool _disposed = false;

int get pid => process.pid;

bool get isDisposed => _disposed;

/// Forcefully terminates the child process (SIGKILL / TerminateProcess).
Future<void> kill() async {
if (_disposed) return;
try {
process.kill(ProcessSignal.sigkill);
} catch (e) {
debugPrint('SandboxProcessHandle.kill($pluginId): $e');
}
try {
await process.exitCode.timeout(const Duration(seconds: 2));
} on TimeoutException {
// Process may already be gone.
} catch (_) {
// Ignore exit-code errors after kill.
}
}

/// Kills the process (if still running) and deletes the scratch directory.
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
try {
process.kill(ProcessSignal.sigterm);
try {
await process.exitCode.timeout(const Duration(seconds: 2));
} on TimeoutException {
process.kill(ProcessSignal.sigkill);
try {
await process.exitCode.timeout(const Duration(seconds: 1));
} catch (_) {}
} catch (_) {}
} catch (e) {
debugPrint('SandboxProcessHandle.dispose($pluginId) kill: $e');
}
await scratch.delete();
}
}

/// Starts Level-2 OS process sandboxes for database-driver extensions.
///
/// Creates a scratch directory, builds a platform launch command
/// (`bwrap` / `sandbox-exec` / direct), and returns a [SandboxProcessHandle]
/// that owns process lifetime and scratch cleanup.
class SandboxProcessRunner {
SandboxProcessRunner({
this.bwrapAvailable,
this.platformOverride,
this.scratchBaseDirectory,
this.processStarter = Process.start,
});

/// Override for tests / environments without bubblewrap.
final bool? bwrapAvailable;

/// Override `linux` / `macos` / `windows` for command-building tests.
final String? platformOverride;

/// Override system temp root for scratch directories (tests).
final Directory? scratchBaseDirectory;

/// Injectable [Process.start] for unit tests.
final Future<Process> Function(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment,
bool runInShell,
ProcessStartMode mode,
}) processStarter;

/// Spawns [pluginExecutable] inside the OS sandbox for [pluginId].
Future<SandboxProcessHandle> start({
required String pluginId,
required String pluginExecutable,
List<String> pluginArguments = const [],
String? extensionRoot,
SandboxCapabilities? capabilities,
Map<String, String>? environment,
}) async {
final scratch = await SandboxScratchDirectory.create(
pluginId: pluginId,
baseDirectory: scratchBaseDirectory,
);

final usesBwrap = bwrapAvailable ?? await _detectBwrap();
final command = SandboxLaunchCommand.build(
pluginExecutable: pluginExecutable,
pluginArguments: pluginArguments,
scratchPath: scratch.path,
extensionRoot: extensionRoot,
capabilities: capabilities,
platformOverride: platformOverride,
bwrapAvailable: usesBwrap,
);

// Never forward parent secrets via environment. Only pass an explicit map
// (credentials go through Stdio JSON-RPC — Block E §5).
final sanitizedEnv = <String, String>{
'QUERYA_SANDBOX_SCRATCH': scratch.path,
'QUERYA_SANDBOX_PLUGIN_ID': pluginId,
if (environment != null) ...environment,
};

try {
final process = await processStarter(
command.executable,
command.arguments,
workingDirectory: scratch.path,
environment: sanitizedEnv,
includeParentEnvironment: false,
runInShell: false,
mode: ProcessStartMode.normal,
);

if (command.platform == 'windows') {
debugPrint(
'SandboxProcessRunner: Windows AppContainer/Job Object soft-start '
'for $pluginId (pid=${process.pid}); full AppContainer lands with '
'native helper.',
);
}

return SandboxProcessHandle(
pluginId: pluginId,
process: process,
scratch: scratch,
launchCommand: command,
);
} catch (e) {
await scratch.delete();
rethrow;
}
}

static Future<bool> _detectBwrap() async {
if (!Platform.isLinux) return false;
try {
final result = await Process.run('which', ['bwrap']);
return result.exitCode == 0;
} catch (_) {
return false;
}
}
}
Loading
Loading