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
11 changes: 8 additions & 3 deletions lib/core/extensions/sandbox/sandbox_launch_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Expand Down Expand Up @@ -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,
Expand All @@ -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"))');
}
Expand Down
22 changes: 21 additions & 1 deletion test/core/extensions/sandbox/sandbox_process_runner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ void main() {
'--ro-bind',
'/',
'/',
'--tmpfs',
'/home',
'--bind',
'/tmp/querya_sandbox/ext_1',
'/tmp/querya_sandbox/ext_1',
Expand All @@ -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',
Expand All @@ -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'],
Expand All @@ -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']);
});
Expand Down
Loading