diff --git a/lib/core/extensions/sandbox/sandbox_launch_command.dart b/lib/core/extensions/sandbox/sandbox_launch_command.dart index 1d6e1f69..97482cf5 100644 --- a/lib/core/extensions/sandbox/sandbox_launch_command.dart +++ b/lib/core/extensions/sandbox/sandbox_launch_command.dart @@ -104,8 +104,10 @@ class SandboxLaunchCommand { '--share-net', '--die-with-parent', '--new-session', - // Root filesystem read-only; scratch and (optional) extension root RW/RO. + // Root filesystem read-only; mask /home with a fresh empty tmpfs to prevent + // exfiltration of SSH keys, cloud credentials, shell histories, and session stores. '--ro-bind', '/', '/', + '--tmpfs', '/home', '--bind', scratchPath, scratchPath, '--chdir', scratchPath, ]; @@ -154,7 +156,8 @@ class SandboxLaunchCommand { } } -/// Seatbelt (sandbox-exec) profile allowing network + scratch RW only. +/// Seatbelt (sandbox-exec) profile allowing network + scratch RW only, +/// explicitly denying access to host user credentials and home directories (/Users). String buildMacOsSeatbeltProfile({ required String scratchPath, String? extensionRoot, @@ -167,7 +170,9 @@ String buildMacOsSeatbeltProfile({ ..writeln('(allow mach-lookup)') ..writeln('(allow network*)') ..writeln('(allow file-read*)') - ..writeln('(allow file-write* (subpath "$scratchPath"))'); + ..writeln('(deny file-read* (subpath "/Users"))') + ..writeln('(allow file-write* (subpath "$scratchPath"))') + ..writeln('(allow file-read* (subpath "$scratchPath"))'); if (extensionRoot != null && extensionRoot.isNotEmpty) { buffer.writeln('(allow file-read* (subpath "$extensionRoot"))'); } diff --git a/test/core/extensions/sandbox/sandbox_process_runner_test.dart b/test/core/extensions/sandbox/sandbox_process_runner_test.dart index 3b94223c..b9e5bc32 100644 --- a/test/core/extensions/sandbox/sandbox_process_runner_test.dart +++ b/test/core/extensions/sandbox/sandbox_process_runner_test.dart @@ -132,6 +132,8 @@ void main() { '--ro-bind', '/', '/', + '--tmpfs', + '/home', '--bind', '/tmp/querya_sandbox/ext_1', '/tmp/querya_sandbox/ext_1', @@ -148,6 +150,22 @@ void main() { expect(cmd.arguments, contains('32')); }); + test('linux bwrap masks host home directory to prevent credential exfiltration', () { + final cmd = SandboxLaunchCommand.build( + pluginExecutable: '/opt/driver', + scratchPath: '/tmp/scratch', + extensionRoot: '/home/user/.querya/extensions/foo', + platformOverride: 'linux', + ); + final homeTmpfsIndex = cmd.arguments.indexOf('/home'); + expect(homeTmpfsIndex, isPositive); + expect(cmd.arguments[homeTmpfsIndex - 1], '--tmpfs'); + + final extRootIndex = cmd.arguments.lastIndexOf('/home/user/.querya/extensions/foo'); + expect(extRootIndex, greaterThan(homeTmpfsIndex), + reason: 'extension root must be re-bound after --tmpfs /home'); + }); + test('linux falls back to direct exec when bwrap missing', () { final cmd = SandboxLaunchCommand.build( pluginExecutable: '/bin/echo', @@ -161,7 +179,7 @@ void main() { expect(cmd.usesOsSandbox, isFalse); }); - test('macos builds sandbox-exec with seatbelt profile', () { + test('macos builds sandbox-exec with seatbelt profile denying /Users', () { final cmd = SandboxLaunchCommand.build( pluginExecutable: '/opt/driver', pluginArguments: const ['a'], @@ -176,7 +194,9 @@ void main() { expect(profile, contains('(version 1)')); expect(profile, contains('(deny default)')); expect(profile, contains('(allow network*)')); + expect(profile, contains('(deny file-read* (subpath "/Users"))')); expect(profile, contains('(allow file-write* (subpath "/tmp/querya_sandbox/p_1"))')); + expect(profile, contains('(allow file-read* (subpath "/tmp/querya_sandbox/p_1"))')); expect(profile, contains('(allow file-read* (subpath "/Users/x/ext"))')); expect(cmd.arguments.sublist(2), ['/opt/driver', 'a']); });