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
15 changes: 13 additions & 2 deletions lib/core/extensions/local_extension_registry.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'dart:convert';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;

import 'extension_paths.dart';
import 'models/extension_manifest.dart';
import 'sandbox/sandbox_policy.dart';

/// Scans the local filesystem for extensions and loads their manifests.
class LocalExtensionRegistry {
Expand Down Expand Up @@ -56,10 +58,19 @@ class LocalExtensionRegistry {
json,
installPath: entity.path,
);
final violations = SandboxPolicy.validate(manifest);
if (violations.isNotEmpty) {
debugPrint(
'LocalExtensionRegistry: skipped "${manifest.id}" — '
'sandbox policy violations: ${violations.join(' ')}',
);
continue;
}
loadedManifests.add(manifest);
} catch (e) {
// Log or ignore invalid manifests
// In the future, we could report these to an error logging service
debugPrint(
'LocalExtensionRegistry: invalid manifest in ${entity.path} ($e)',
);
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions lib/core/extensions/models/extension_manifest.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '../../theme/theme_definition.dart';
import 'extension_type.dart';
import 'sandbox_capabilities.dart';

class ExtensionManifest {
static const typeTheme = ExtensionType.theme;
Expand All @@ -22,6 +23,10 @@ class ExtensionManifest {
final String? preview;
final List<String> tags;

/// Sandbox requirements declared by the extension (Block E). Null when the
/// manifest has no `sandbox` block (e.g. plain themes).
final SandboxCapabilities? sandbox;

const ExtensionManifest({
required this.id,
required this.name,
Expand All @@ -40,6 +45,7 @@ class ExtensionManifest {
this.license,
this.preview,
this.tags = const [],
this.sandbox,
});

/// Maps a registry [ThemeDefinition] into marketplace field names.
Expand Down Expand Up @@ -89,6 +95,9 @@ class ExtensionManifest {
license: json['license'] as String?,
preview: json['preview'] as String?,
tags: List<String>.from(json['tags'] as List? ?? []),
sandbox: json['sandbox'] is Map<String, dynamic>
? SandboxCapabilities.fromJson(json['sandbox'] as Map<String, dynamic>)
: null,
);
}

Expand All @@ -110,6 +119,7 @@ class ExtensionManifest {
if (license != null) 'license': license,
if (preview != null) 'preview': preview,
if (tags.isNotEmpty) 'tags': tags,
if (sandbox != null) 'sandbox': sandbox!.toJson(),
};
}
}
183 changes: 183 additions & 0 deletions lib/core/extensions/models/sandbox_capabilities.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/// Sandbox declaration parsed from the `sandbox` block of an extension
/// manifest (Block E — Sandbox Runtime).
///
/// Example manifest fragment:
/// ```json
/// "sandbox": {
/// "engine": "process",
/// "permissions": {
/// "network": { "mode": "connection_host_only", "allow_ssl": true },
/// "filesystem": { "scratch_mb": 100, "access": "scratch_only" },
/// "resources": { "memory_mb": 256, "max_open_files": 64 }
/// }
/// }
/// ```
library;

/// Execution engine requested by the extension.
enum SandboxEngine {
/// Level 2 — managed OS process (bwrap / sandbox-exec / AppContainer).
process('process'),

/// Level 1 — embedded WASM runtime inside the host process.
wasm('wasm'),

/// Level 1 — embedded QuickJS runtime inside the host process.
quickjs('quickjs'),

unknown('unknown');

const SandboxEngine(this.value);
final String value;

static SandboxEngine fromString(String? value) {
if (value == null) return SandboxEngine.unknown;
return SandboxEngine.values.firstWhere(
(e) => e.value == value,
orElse: () => SandboxEngine.unknown,
);
}

/// Embedded engines run in-process with full memory isolation.
bool get isEmbedded => this == SandboxEngine.wasm || this == SandboxEngine.quickjs;
}

/// Network access mode requested by the extension.
enum NetworkPermissionMode {
/// No sockets at all (themes, parsers, SDUI transformers).
none('none'),

/// Outgoing TCP/TLS only to the user-configured `connection.host:port`.
connectionHostOnly('connection_host_only'),

unknown('unknown');

const NetworkPermissionMode(this.value);
final String value;

static NetworkPermissionMode fromString(String? value) {
if (value == null) return NetworkPermissionMode.none;
return NetworkPermissionMode.values.firstWhere(
(e) => e.value == value,
orElse: () => NetworkPermissionMode.unknown,
);
}
}

class NetworkPermission {
const NetworkPermission({
this.mode = NetworkPermissionMode.none,
this.allowSsl = false,
});

final NetworkPermissionMode mode;
final bool allowSsl;

factory NetworkPermission.fromJson(Map<String, dynamic> json) {
return NetworkPermission(
mode: NetworkPermissionMode.fromString(json['mode'] as String?),
allowSsl: json['allow_ssl'] as bool? ?? false,
);
}

Map<String, dynamic> toJson() => {
'mode': mode.value,
'allow_ssl': allowSsl,
};
}

class FilesystemPermission {
const FilesystemPermission({
this.scratchMb = defaultScratchMb,
this.access = scratchOnlyAccess,
});

static const scratchOnlyAccess = 'scratch_only';
static const defaultScratchMb = 100;

/// Quota for the read-write scratch directory, in megabytes.
final int scratchMb;

/// Filesystem access scope. Only [scratchOnlyAccess] is supported.
final String access;

factory FilesystemPermission.fromJson(Map<String, dynamic> json) {
return FilesystemPermission(
scratchMb: json['scratch_mb'] as int? ?? defaultScratchMb,
access: json['access'] as String? ?? scratchOnlyAccess,
);
}

Map<String, dynamic> toJson() => {
'scratch_mb': scratchMb,
'access': access,
};
}

class ResourceLimits {
const ResourceLimits({
this.memoryMb = defaultMemoryMb,
this.maxOpenFiles = defaultMaxOpenFiles,
});

static const defaultMemoryMb = 256;
static const defaultMaxOpenFiles = 64;

/// Hard RAM limit for the plugin process, in megabytes.
final int memoryMb;

/// Maximum number of open file descriptors (`ulimit -n`).
final int maxOpenFiles;

factory ResourceLimits.fromJson(Map<String, dynamic> json) {
return ResourceLimits(
memoryMb: json['memory_mb'] as int? ?? defaultMemoryMb,
maxOpenFiles: json['max_open_files'] as int? ?? defaultMaxOpenFiles,
);
}

Map<String, dynamic> toJson() => {
'memory_mb': memoryMb,
'max_open_files': maxOpenFiles,
};
}

/// Full sandbox requirements block declared by an extension.
class SandboxCapabilities {
const SandboxCapabilities({
required this.engine,
this.network = const NetworkPermission(),
this.filesystem = const FilesystemPermission(),
this.resources = const ResourceLimits(),
});

final SandboxEngine engine;
final NetworkPermission network;
final FilesystemPermission filesystem;
final ResourceLimits resources;

factory SandboxCapabilities.fromJson(Map<String, dynamic> json) {
final permissions = json['permissions'] as Map<String, dynamic>? ?? const {};
return SandboxCapabilities(
engine: SandboxEngine.fromString(json['engine'] as String?),
network: permissions['network'] is Map<String, dynamic>
? NetworkPermission.fromJson(permissions['network'] as Map<String, dynamic>)
: const NetworkPermission(),
filesystem: permissions['filesystem'] is Map<String, dynamic>
? FilesystemPermission.fromJson(permissions['filesystem'] as Map<String, dynamic>)
: const FilesystemPermission(),
resources: permissions['resources'] is Map<String, dynamic>
? ResourceLimits.fromJson(permissions['resources'] as Map<String, dynamic>)
: const ResourceLimits(),
);
}

Map<String, dynamic> toJson() => {
'engine': engine.value,
'permissions': {
'network': network.toJson(),
'filesystem': filesystem.toJson(),
'resources': resources.toJson(),
},
};
}
92 changes: 92 additions & 0 deletions lib/core/extensions/sandbox/sandbox_policy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart';

/// Security policy limits for sandboxed extensions (Block E, section 3).
///
/// Validates that the `sandbox` block declared in a manifest does not request
/// more than the allowed policy for its [ExtensionType]. Used by
/// `LocalExtensionRegistry` when loading and `MarketplaceRepository.install()`
/// before registration.
class SandboxPolicy {
SandboxPolicy._();

/// Hard scratch directory quota, MB.
static const maxScratchMb = 100;

/// Hard RAM ceiling for heavy OLAP drivers, MB.
static const maxMemoryMb = 512;

/// Hard file descriptor ceiling.
static const maxOpenFiles = 64;

/// Returns a list of policy violations; empty means the manifest is allowed.
static List<String> validate(ExtensionManifest manifest) {
final sandbox = manifest.sandbox;
if (sandbox == null) return const [];

final errors = <String>[];
final type = manifest.type;

if (sandbox.engine == SandboxEngine.unknown) {
errors.add('Unknown sandbox engine.');
}

if (sandbox.network.mode == NetworkPermissionMode.unknown) {
errors.add('Unknown network permission mode.');
}

// OS process sandbox (Level 2) is reserved for database drivers.
if (sandbox.engine == SandboxEngine.process &&
type != ExtensionType.databaseDriver) {
errors.add(
'Sandbox engine "process" is only allowed for database drivers.',
);
}

// Network sockets are only allowed for database drivers, and only to the
// user-configured connection host.
if (sandbox.network.mode == NetworkPermissionMode.connectionHostOnly &&
type != ExtensionType.databaseDriver) {
errors.add(
'Network access is not allowed for extensions of type "${type.value}".',
);
}

if (sandbox.filesystem.access != FilesystemPermission.scratchOnlyAccess) {
errors.add(
'Filesystem access "${sandbox.filesystem.access}" is not allowed; '
'only "${FilesystemPermission.scratchOnlyAccess}" is supported.',
);
}

if (sandbox.filesystem.scratchMb <= 0 ||
sandbox.filesystem.scratchMb > maxScratchMb) {
errors.add(
'Scratch quota ${sandbox.filesystem.scratchMb} MB exceeds the '
'$maxScratchMb MB limit.',
);
}

if (sandbox.resources.memoryMb <= 0 ||
sandbox.resources.memoryMb > maxMemoryMb) {
errors.add(
'Memory limit ${sandbox.resources.memoryMb} MB exceeds the '
'$maxMemoryMb MB ceiling.',
);
}

if (sandbox.resources.maxOpenFiles <= 0 ||
sandbox.resources.maxOpenFiles > maxOpenFiles) {
errors.add(
'File descriptor limit ${sandbox.resources.maxOpenFiles} exceeds '
'the $maxOpenFiles ceiling.',
);
}

return errors;
}

static bool isAllowed(ExtensionManifest manifest) =>
validate(manifest).isEmpty;
}
9 changes: 9 additions & 0 deletions lib/core/market/http_marketplace_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/extensions/extension_support.dart';
import 'package:querya_desktop/core/extensions/extension_paths.dart';
import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart';
import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
Expand Down Expand Up @@ -102,6 +103,14 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice);
}

final sandboxViolations = SandboxPolicy.validate(manifest);
if (sandboxViolations.isNotEmpty) {
throw MarketplaceException(
'Extension "${manifest.id}" requests sandbox permissions beyond the '
'security policy: ${sandboxViolations.join(' ')}',
);
}

final downloadUrl = manifest.downloadUrl;
if (downloadUrl == null || downloadUrl.trim().isEmpty) {
throw MarketplaceException('Extension manifest is missing downloadUrl');
Expand Down
Loading
Loading