@voratiq/sandbox-runtime 0.0.29-voratiq0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +17 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +158 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/index.d.ts +12 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +9 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/sandbox/generate-seccomp-filter.d.ts +65 -0
  14. package/dist/sandbox/generate-seccomp-filter.d.ts.map +1 -0
  15. package/dist/sandbox/generate-seccomp-filter.js +185 -0
  16. package/dist/sandbox/generate-seccomp-filter.js.map +1 -0
  17. package/dist/sandbox/http-proxy.d.ts +14 -0
  18. package/dist/sandbox/http-proxy.d.ts.map +1 -0
  19. package/dist/sandbox/http-proxy.js +238 -0
  20. package/dist/sandbox/http-proxy.js.map +1 -0
  21. package/dist/sandbox/linux-sandbox-utils.d.ts +121 -0
  22. package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -0
  23. package/dist/sandbox/linux-sandbox-utils.js +723 -0
  24. package/dist/sandbox/linux-sandbox-utils.js.map +1 -0
  25. package/dist/sandbox/macos-sandbox-utils.d.ts +57 -0
  26. package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -0
  27. package/dist/sandbox/macos-sandbox-utils.js +611 -0
  28. package/dist/sandbox/macos-sandbox-utils.js.map +1 -0
  29. package/dist/sandbox/observability.d.ts +56 -0
  30. package/dist/sandbox/observability.d.ts.map +1 -0
  31. package/dist/sandbox/observability.js +140 -0
  32. package/dist/sandbox/observability.js.map +1 -0
  33. package/dist/sandbox/sandbox-config.d.ts +277 -0
  34. package/dist/sandbox/sandbox-config.d.ts.map +1 -0
  35. package/dist/sandbox/sandbox-config.js +166 -0
  36. package/dist/sandbox/sandbox-config.js.map +1 -0
  37. package/dist/sandbox/sandbox-manager.d.ts +50 -0
  38. package/dist/sandbox/sandbox-manager.d.ts.map +1 -0
  39. package/dist/sandbox/sandbox-manager.js +816 -0
  40. package/dist/sandbox/sandbox-manager.js.map +1 -0
  41. package/dist/sandbox/sandbox-schemas.d.ts +53 -0
  42. package/dist/sandbox/sandbox-schemas.d.ts.map +1 -0
  43. package/dist/sandbox/sandbox-schemas.js +3 -0
  44. package/dist/sandbox/sandbox-schemas.js.map +1 -0
  45. package/dist/sandbox/sandbox-utils.d.ts +83 -0
  46. package/dist/sandbox/sandbox-utils.d.ts.map +1 -0
  47. package/dist/sandbox/sandbox-utils.js +343 -0
  48. package/dist/sandbox/sandbox-utils.js.map +1 -0
  49. package/dist/sandbox/sandbox-violation-store.d.ts +19 -0
  50. package/dist/sandbox/sandbox-violation-store.d.ts.map +1 -0
  51. package/dist/sandbox/sandbox-violation-store.js +54 -0
  52. package/dist/sandbox/sandbox-violation-store.js.map +1 -0
  53. package/dist/sandbox/socks-proxy.d.ts +14 -0
  54. package/dist/sandbox/socks-proxy.d.ts.map +1 -0
  55. package/dist/sandbox/socks-proxy.js +109 -0
  56. package/dist/sandbox/socks-proxy.js.map +1 -0
  57. package/dist/utils/config-loader.d.ts +11 -0
  58. package/dist/utils/config-loader.d.ts.map +1 -0
  59. package/dist/utils/config-loader.js +60 -0
  60. package/dist/utils/config-loader.js.map +1 -0
  61. package/dist/utils/debug.d.ts +7 -0
  62. package/dist/utils/debug.d.ts.map +1 -0
  63. package/dist/utils/debug.js +25 -0
  64. package/dist/utils/debug.js.map +1 -0
  65. package/dist/utils/platform.d.ts +15 -0
  66. package/dist/utils/platform.d.ts.map +1 -0
  67. package/dist/utils/platform.js +49 -0
  68. package/dist/utils/platform.js.map +1 -0
  69. package/dist/utils/ripgrep.d.ts +20 -0
  70. package/dist/utils/ripgrep.d.ts.map +1 -0
  71. package/dist/utils/ripgrep.js +51 -0
  72. package/dist/utils/ripgrep.js.map +1 -0
  73. package/dist/vendor/seccomp/arm64/apply-seccomp +0 -0
  74. package/dist/vendor/seccomp/arm64/unix-block.bpf +0 -0
  75. package/dist/vendor/seccomp/x64/apply-seccomp +0 -0
  76. package/dist/vendor/seccomp/x64/unix-block.bpf +0 -0
  77. package/dist/vendor/seccomp-src/apply-seccomp.c +98 -0
  78. package/dist/vendor/seccomp-src/seccomp-unix-block.c +97 -0
  79. package/package.json +90 -0
  80. package/vendor/seccomp/arm64/apply-seccomp +0 -0
  81. package/vendor/seccomp/arm64/unix-block.bpf +0 -0
  82. package/vendor/seccomp/x64/apply-seccomp +0 -0
  83. package/vendor/seccomp/x64/unix-block.bpf +0 -0
  84. package/vendor/seccomp-src/apply-seccomp.c +98 -0
  85. package/vendor/seccomp-src/seccomp-unix-block.c +97 -0
@@ -0,0 +1,723 @@
1
+ import shellquote from 'shell-quote';
2
+ import { logForDebugging } from '../utils/debug.js';
3
+ import { randomBytes } from 'node:crypto';
4
+ import * as fs from 'fs';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { tmpdir } from 'node:os';
7
+ import path, { join } from 'node:path';
8
+ import { ripGrep } from '../utils/ripgrep.js';
9
+ import { generateProxyEnvVars, normalizePathForSandbox, normalizeCaseForComparison, DANGEROUS_FILES, getDangerousDirectories, } from './sandbox-utils.js';
10
+ import { generateSeccompFilter, cleanupSeccompFilter, getPreGeneratedBpfPath, getApplySeccompBinaryPath, } from './generate-seccomp-filter.js';
11
+ /** Default max depth for searching dangerous files */
12
+ const DEFAULT_MANDATORY_DENY_SEARCH_DEPTH = 3;
13
+ /**
14
+ * Find if any component of the path is a symlink within the allowed write paths.
15
+ * Returns the symlink path if found, or null if no symlinks.
16
+ *
17
+ * This is used to detect and block symlink replacement attacks where an attacker
18
+ * could delete a symlink and create a real directory with malicious content.
19
+ */
20
+ function findSymlinkInPath(targetPath, allowedWritePaths) {
21
+ const parts = targetPath.split(path.sep);
22
+ let currentPath = '';
23
+ for (const part of parts) {
24
+ if (!part)
25
+ continue; // Skip empty parts (leading /)
26
+ const nextPath = currentPath + path.sep + part;
27
+ try {
28
+ const stats = fs.lstatSync(nextPath);
29
+ if (stats.isSymbolicLink()) {
30
+ // Check if this symlink is within an allowed write path
31
+ const isWithinAllowedPath = allowedWritePaths.some(allowedPath => nextPath.startsWith(allowedPath + '/') || nextPath === allowedPath);
32
+ if (isWithinAllowedPath) {
33
+ return nextPath;
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ // Path doesn't exist - no symlink issue here
39
+ break;
40
+ }
41
+ currentPath = nextPath;
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Find the first non-existent path component.
47
+ * E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists,
48
+ * returns "/existing/parent/nonexistent"
49
+ *
50
+ * This is used to block creation of non-existent deny paths by mounting /dev/null
51
+ * at the first missing component, preventing mkdir from creating the parent directories.
52
+ */
53
+ function findFirstNonExistentComponent(targetPath) {
54
+ const parts = targetPath.split(path.sep);
55
+ let currentPath = '';
56
+ for (const part of parts) {
57
+ if (!part)
58
+ continue; // Skip empty parts (leading /)
59
+ const nextPath = currentPath + path.sep + part;
60
+ if (!fs.existsSync(nextPath)) {
61
+ return nextPath;
62
+ }
63
+ currentPath = nextPath;
64
+ }
65
+ return targetPath; // Shouldn't reach here if called correctly
66
+ }
67
+ /**
68
+ * Get mandatory deny paths using ripgrep (Linux only).
69
+ * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency.
70
+ * With --max-depth limiting, this is fast enough to run on each command without memoization.
71
+ */
72
+ async function linuxGetMandatoryDenyPaths(ripgrepConfig = { command: 'rg' }, maxDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal) {
73
+ const cwd = process.cwd();
74
+ // Use provided signal or create a fallback controller
75
+ const fallbackController = new AbortController();
76
+ const signal = abortSignal ?? fallbackController.signal;
77
+ const dangerousDirectories = getDangerousDirectories();
78
+ // Note: Settings files are added at the callsite in sandbox-manager.ts
79
+ const denyPaths = [
80
+ // Dangerous files in CWD
81
+ ...DANGEROUS_FILES.map(f => path.resolve(cwd, f)),
82
+ // Dangerous directories in CWD
83
+ ...dangerousDirectories.map(d => path.resolve(cwd, d)),
84
+ // Git hooks always blocked for security
85
+ path.resolve(cwd, '.git/hooks'),
86
+ ];
87
+ // Git config conditionally blocked based on allowGitConfig setting
88
+ if (!allowGitConfig) {
89
+ denyPaths.push(path.resolve(cwd, '.git/config'));
90
+ }
91
+ // Build iglob args for all patterns in one ripgrep call
92
+ const iglobArgs = [];
93
+ for (const fileName of DANGEROUS_FILES) {
94
+ iglobArgs.push('--iglob', fileName);
95
+ }
96
+ for (const dirName of dangerousDirectories) {
97
+ iglobArgs.push('--iglob', `**/${dirName}/**`);
98
+ }
99
+ // Git hooks always blocked in nested repos
100
+ iglobArgs.push('--iglob', '**/.git/hooks/**');
101
+ // Git config conditionally blocked in nested repos
102
+ if (!allowGitConfig) {
103
+ iglobArgs.push('--iglob', '**/.git/config');
104
+ }
105
+ // Single ripgrep call to find all dangerous paths in subdirectories
106
+ // Limit depth for performance - deeply nested dangerous files are rare
107
+ // and the security benefit doesn't justify the traversal cost
108
+ let matches = [];
109
+ try {
110
+ matches = await ripGrep([
111
+ '--files',
112
+ '--hidden',
113
+ '--max-depth',
114
+ String(maxDepth),
115
+ ...iglobArgs,
116
+ '-g',
117
+ '!**/node_modules/**',
118
+ ], cwd, signal, ripgrepConfig);
119
+ }
120
+ catch (error) {
121
+ logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`);
122
+ }
123
+ // Process matches
124
+ for (const match of matches) {
125
+ const absolutePath = path.resolve(cwd, match);
126
+ // File inside a dangerous directory -> add the directory path
127
+ let foundDir = false;
128
+ for (const dirName of [...dangerousDirectories, '.git']) {
129
+ const normalizedDirName = normalizeCaseForComparison(dirName);
130
+ const segments = absolutePath.split(path.sep);
131
+ const dirIndex = segments.findIndex(s => normalizeCaseForComparison(s) === normalizedDirName);
132
+ if (dirIndex !== -1) {
133
+ // For .git, we want hooks/ or config, not the whole .git dir
134
+ if (dirName === '.git') {
135
+ const gitDir = segments.slice(0, dirIndex + 1).join(path.sep);
136
+ if (match.includes('.git/hooks')) {
137
+ denyPaths.push(path.join(gitDir, 'hooks'));
138
+ }
139
+ else if (match.includes('.git/config')) {
140
+ denyPaths.push(path.join(gitDir, 'config'));
141
+ }
142
+ }
143
+ else {
144
+ denyPaths.push(segments.slice(0, dirIndex + 1).join(path.sep));
145
+ }
146
+ foundDir = true;
147
+ break;
148
+ }
149
+ }
150
+ // Dangerous file match
151
+ if (!foundDir) {
152
+ denyPaths.push(absolutePath);
153
+ }
154
+ }
155
+ return [...new Set(denyPaths)];
156
+ }
157
+ // Track generated seccomp filters for cleanup on process exit
158
+ const generatedSeccompFilters = new Set();
159
+ let exitHandlerRegistered = false;
160
+ /**
161
+ * Register cleanup handler for generated seccomp filters
162
+ */
163
+ function registerSeccompCleanupHandler() {
164
+ if (exitHandlerRegistered) {
165
+ return;
166
+ }
167
+ process.on('exit', () => {
168
+ for (const filterPath of generatedSeccompFilters) {
169
+ try {
170
+ cleanupSeccompFilter(filterPath);
171
+ }
172
+ catch {
173
+ // Ignore cleanup errors during exit
174
+ }
175
+ }
176
+ });
177
+ exitHandlerRegistered = true;
178
+ }
179
+ /**
180
+ * Check if Linux sandbox dependencies are available (synchronous)
181
+ * Returns true if bwrap and socat are installed.
182
+ */
183
+ export function hasLinuxSandboxDependenciesSync(allowAllUnixSockets = false, seccompConfig) {
184
+ try {
185
+ const bwrapResult = spawnSync('which', ['bwrap'], {
186
+ stdio: 'ignore',
187
+ timeout: 1000,
188
+ });
189
+ const socatResult = spawnSync('which', ['socat'], {
190
+ stdio: 'ignore',
191
+ timeout: 1000,
192
+ });
193
+ const hasBasicDeps = bwrapResult.status === 0 && socatResult.status === 0;
194
+ // Check for seccomp dependencies (optional security feature)
195
+ if (!allowAllUnixSockets) {
196
+ // Check if we have a pre-generated BPF filter for this architecture
197
+ const hasPreGeneratedBpf = getPreGeneratedBpfPath(seccompConfig?.bpfPath) !== null;
198
+ // Check if we have the apply-seccomp binary for this architecture
199
+ const hasApplySeccompBinary = getApplySeccompBinaryPath(seccompConfig?.applyPath) !== null;
200
+ if (!hasPreGeneratedBpf || !hasApplySeccompBinary) {
201
+ // Seccomp not available - log warning but continue with basic sandbox
202
+ // The sandbox will gracefully fall back to allowAllUnixSockets mode
203
+ logForDebugging(`[Sandbox Linux] Seccomp filtering not available (missing binaries for ${process.arch}). ` +
204
+ `Sandbox will run without Unix socket blocking (allowAllUnixSockets mode). ` +
205
+ `This is less restrictive but still provides filesystem and network isolation.`, { level: 'warn' });
206
+ }
207
+ }
208
+ return hasBasicDeps;
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
214
+ /**
215
+ * Initialize the Linux network bridge for sandbox networking
216
+ *
217
+ * ARCHITECTURE NOTE:
218
+ * Linux network sandboxing uses bwrap --unshare-net which creates a completely isolated
219
+ * network namespace with NO network access. To enable network access, we:
220
+ *
221
+ * 1. Host side: Run socat bridges that listen on Unix sockets and forward to host proxy servers
222
+ * - HTTP bridge: Unix socket -> host HTTP proxy (for HTTP/HTTPS traffic)
223
+ * - SOCKS bridge: Unix socket -> host SOCKS5 proxy (for SSH/git traffic)
224
+ *
225
+ * 2. Sandbox side: Bind the Unix sockets into the isolated namespace and run socat listeners
226
+ * - HTTP listener on port 3128 -> HTTP Unix socket -> host HTTP proxy
227
+ * - SOCKS listener on port 1080 -> SOCKS Unix socket -> host SOCKS5 proxy
228
+ *
229
+ * 3. Configure environment:
230
+ * - HTTP_PROXY=http://localhost:3128 for HTTP/HTTPS tools
231
+ * - GIT_SSH_COMMAND with socat for SSH through SOCKS5
232
+ *
233
+ * LIMITATION: Unlike macOS sandbox which can enforce domain-based allowlists at the kernel level,
234
+ * Linux's --unshare-net provides only all-or-nothing network isolation. Domain filtering happens
235
+ * at the host proxy level, not the sandbox boundary. This means network restrictions on Linux
236
+ * depend on the proxy's filtering capabilities.
237
+ *
238
+ * DEPENDENCIES: Requires bwrap (bubblewrap) and socat
239
+ */
240
+ export async function initializeLinuxNetworkBridge(httpProxyPort, socksProxyPort) {
241
+ const socketId = randomBytes(8).toString('hex');
242
+ const httpSocketPath = join(tmpdir(), `claude-http-${socketId}.sock`);
243
+ const socksSocketPath = join(tmpdir(), `claude-socks-${socketId}.sock`);
244
+ // Start HTTP bridge
245
+ const httpSocatArgs = [
246
+ `UNIX-LISTEN:${httpSocketPath},fork,reuseaddr`,
247
+ `TCP:localhost:${httpProxyPort},keepalive,keepidle=10,keepintvl=5,keepcnt=3`,
248
+ ];
249
+ logForDebugging(`Starting HTTP bridge: socat ${httpSocatArgs.join(' ')}`);
250
+ const httpBridgeProcess = spawn('socat', httpSocatArgs, {
251
+ stdio: 'ignore',
252
+ });
253
+ if (!httpBridgeProcess.pid) {
254
+ throw new Error('Failed to start HTTP bridge process');
255
+ }
256
+ // Add error and exit handlers to monitor bridge health
257
+ httpBridgeProcess.on('error', err => {
258
+ logForDebugging(`HTTP bridge process error: ${err}`, { level: 'error' });
259
+ });
260
+ httpBridgeProcess.on('exit', (code, signal) => {
261
+ logForDebugging(`HTTP bridge process exited with code ${code}, signal ${signal}`, { level: code === 0 ? 'info' : 'error' });
262
+ });
263
+ // Start SOCKS bridge
264
+ const socksSocatArgs = [
265
+ `UNIX-LISTEN:${socksSocketPath},fork,reuseaddr`,
266
+ `TCP:localhost:${socksProxyPort},keepalive,keepidle=10,keepintvl=5,keepcnt=3`,
267
+ ];
268
+ logForDebugging(`Starting SOCKS bridge: socat ${socksSocatArgs.join(' ')}`);
269
+ const socksBridgeProcess = spawn('socat', socksSocatArgs, {
270
+ stdio: 'ignore',
271
+ });
272
+ if (!socksBridgeProcess.pid) {
273
+ // Clean up HTTP bridge
274
+ if (httpBridgeProcess.pid) {
275
+ try {
276
+ process.kill(httpBridgeProcess.pid, 'SIGTERM');
277
+ }
278
+ catch {
279
+ // Ignore errors
280
+ }
281
+ }
282
+ throw new Error('Failed to start SOCKS bridge process');
283
+ }
284
+ // Add error and exit handlers to monitor bridge health
285
+ socksBridgeProcess.on('error', err => {
286
+ logForDebugging(`SOCKS bridge process error: ${err}`, { level: 'error' });
287
+ });
288
+ socksBridgeProcess.on('exit', (code, signal) => {
289
+ logForDebugging(`SOCKS bridge process exited with code ${code}, signal ${signal}`, { level: code === 0 ? 'info' : 'error' });
290
+ });
291
+ // Wait for both sockets to be ready
292
+ const maxAttempts = 5;
293
+ for (let i = 0; i < maxAttempts; i++) {
294
+ if (!httpBridgeProcess.pid ||
295
+ httpBridgeProcess.killed ||
296
+ !socksBridgeProcess.pid ||
297
+ socksBridgeProcess.killed) {
298
+ throw new Error('Linux bridge process died unexpectedly');
299
+ }
300
+ try {
301
+ // fs already imported
302
+ if (fs.existsSync(httpSocketPath) && fs.existsSync(socksSocketPath)) {
303
+ logForDebugging(`Linux bridges ready after ${i + 1} attempts`);
304
+ break;
305
+ }
306
+ }
307
+ catch (err) {
308
+ logForDebugging(`Error checking sockets (attempt ${i + 1}): ${err}`, {
309
+ level: 'error',
310
+ });
311
+ }
312
+ if (i === maxAttempts - 1) {
313
+ // Clean up both processes
314
+ if (httpBridgeProcess.pid) {
315
+ try {
316
+ process.kill(httpBridgeProcess.pid, 'SIGTERM');
317
+ }
318
+ catch {
319
+ // Ignore errors
320
+ }
321
+ }
322
+ if (socksBridgeProcess.pid) {
323
+ try {
324
+ process.kill(socksBridgeProcess.pid, 'SIGTERM');
325
+ }
326
+ catch {
327
+ // Ignore errors
328
+ }
329
+ }
330
+ throw new Error(`Failed to create bridge sockets after ${maxAttempts} attempts`);
331
+ }
332
+ await new Promise(resolve => setTimeout(resolve, i * 100));
333
+ }
334
+ return {
335
+ httpSocketPath,
336
+ socksSocketPath,
337
+ httpBridgeProcess,
338
+ socksBridgeProcess,
339
+ httpProxyPort,
340
+ socksProxyPort,
341
+ };
342
+ }
343
+ /**
344
+ * Build the command that runs inside the sandbox.
345
+ * Sets up HTTP proxy on port 3128 and SOCKS proxy on port 1080
346
+ */
347
+ function buildSandboxCommand(httpSocketPath, socksSocketPath, userCommand, seccompFilterPath, shell, applySeccompPath) {
348
+ // Default to bash for backward compatibility
349
+ const shellPath = shell || 'bash';
350
+ const socatCommands = [
351
+ `socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:${httpSocketPath} >/dev/null 2>&1 &`,
352
+ `socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:${socksSocketPath} >/dev/null 2>&1 &`,
353
+ 'trap "kill %1 %2 2>/dev/null; exit" EXIT',
354
+ ];
355
+ // If seccomp filter is provided, use apply-seccomp to apply it
356
+ if (seccompFilterPath) {
357
+ // apply-seccomp approach:
358
+ // 1. Outer bwrap/bash: starts socat processes (can use Unix sockets)
359
+ // 2. apply-seccomp: applies seccomp filter and execs user command
360
+ // 3. User command runs with seccomp active (Unix sockets blocked)
361
+ //
362
+ // apply-seccomp is a simple C program that:
363
+ // - Sets PR_SET_NO_NEW_PRIVS
364
+ // - Applies the seccomp BPF filter via prctl(PR_SET_SECCOMP)
365
+ // - Execs the user command
366
+ //
367
+ // This is simpler and more portable than nested bwrap, with no FD redirects needed.
368
+ const applySeccompBinary = getApplySeccompBinaryPath(applySeccompPath);
369
+ if (!applySeccompBinary) {
370
+ throw new Error('apply-seccomp binary not found. This should have been caught earlier. ' +
371
+ 'Ensure vendor/seccomp/{x64,arm64}/apply-seccomp binaries are included in the package.');
372
+ }
373
+ const applySeccompCmd = shellquote.quote([
374
+ applySeccompBinary,
375
+ seccompFilterPath,
376
+ shellPath,
377
+ '-c',
378
+ userCommand,
379
+ ]);
380
+ const innerScript = [...socatCommands, applySeccompCmd].join('\n');
381
+ return `${shellPath} -c ${shellquote.quote([innerScript])}`;
382
+ }
383
+ else {
384
+ // No seccomp filter - run user command directly
385
+ const innerScript = [
386
+ ...socatCommands,
387
+ `eval ${shellquote.quote([userCommand])}`,
388
+ ].join('\n');
389
+ return `${shellPath} -c ${shellquote.quote([innerScript])}`;
390
+ }
391
+ }
392
+ /**
393
+ * Generate filesystem bind mount arguments for bwrap
394
+ */
395
+ async function generateFilesystemArgs(readConfig, writeConfig, ripgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal) {
396
+ const args = [];
397
+ // fs already imported
398
+ // Determine initial root mount based on write restrictions
399
+ if (writeConfig) {
400
+ // Write restrictions: Start with read-only root, then allow writes to specific paths
401
+ args.push('--ro-bind', '/', '/');
402
+ // Collect normalized allowed write paths for later checking
403
+ const allowedWritePaths = [];
404
+ // Allow writes to specific paths
405
+ for (const pathPattern of writeConfig.allowOnly || []) {
406
+ const normalizedPath = normalizePathForSandbox(pathPattern);
407
+ logForDebugging(`[Sandbox Linux] Processing write path: ${pathPattern} -> ${normalizedPath}`);
408
+ // Skip /dev/* paths since --dev /dev already handles them
409
+ if (normalizedPath.startsWith('/dev/')) {
410
+ logForDebugging(`[Sandbox Linux] Skipping /dev path: ${normalizedPath}`);
411
+ continue;
412
+ }
413
+ if (!fs.existsSync(normalizedPath)) {
414
+ logForDebugging(`[Sandbox Linux] Skipping non-existent write path: ${normalizedPath}`);
415
+ continue;
416
+ }
417
+ args.push('--bind', normalizedPath, normalizedPath);
418
+ allowedWritePaths.push(normalizedPath);
419
+ }
420
+ // Deny writes within allowed paths (user-specified + mandatory denies)
421
+ const denyPaths = [
422
+ ...(writeConfig.denyWithinAllow || []),
423
+ ...(await linuxGetMandatoryDenyPaths(ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal)),
424
+ ];
425
+ for (const pathPattern of denyPaths) {
426
+ const normalizedPath = normalizePathForSandbox(pathPattern);
427
+ // Skip /dev/* paths since --dev /dev already handles them
428
+ if (normalizedPath.startsWith('/dev/')) {
429
+ continue;
430
+ }
431
+ // Check for symlinks in the path - if any parent component is a symlink,
432
+ // mount /dev/null there to prevent symlink replacement attacks.
433
+ // Attack scenario: .claude is a symlink to ./decoy/, attacker deletes
434
+ // symlink and creates real .claude/settings.json with malicious hooks.
435
+ const symlinkInPath = findSymlinkInPath(normalizedPath, allowedWritePaths);
436
+ if (symlinkInPath) {
437
+ args.push('--ro-bind', '/dev/null', symlinkInPath);
438
+ logForDebugging(`[Sandbox Linux] Mounted /dev/null at symlink ${symlinkInPath} to prevent symlink replacement attack`);
439
+ continue;
440
+ }
441
+ // Handle non-existent paths by mounting /dev/null to block creation
442
+ if (!fs.existsSync(normalizedPath)) {
443
+ // Find the deepest existing ancestor directory
444
+ let ancestorPath = path.dirname(normalizedPath);
445
+ while (ancestorPath !== '/' && !fs.existsSync(ancestorPath)) {
446
+ ancestorPath = path.dirname(ancestorPath);
447
+ }
448
+ // Only protect if the existing ancestor is within an allowed write path
449
+ const ancestorIsWithinAllowedPath = allowedWritePaths.some(allowedPath => ancestorPath.startsWith(allowedPath + '/') ||
450
+ ancestorPath === allowedPath ||
451
+ normalizedPath.startsWith(allowedPath + '/'));
452
+ if (ancestorIsWithinAllowedPath) {
453
+ // Mount /dev/null at the first non-existent path component
454
+ // This blocks creation of the entire path by making the first
455
+ // missing component appear as an empty file (mkdir will fail)
456
+ const firstNonExistent = findFirstNonExistentComponent(normalizedPath);
457
+ args.push('--ro-bind', '/dev/null', firstNonExistent);
458
+ logForDebugging(`[Sandbox Linux] Mounted /dev/null at ${firstNonExistent} to block creation of ${normalizedPath}`);
459
+ }
460
+ else {
461
+ logForDebugging(`[Sandbox Linux] Skipping non-existent deny path not within allowed paths: ${normalizedPath}`);
462
+ }
463
+ continue;
464
+ }
465
+ // Only add deny binding if this path is within an allowed write path
466
+ // Otherwise it's already read-only from the initial --ro-bind / /
467
+ const isWithinAllowedPath = allowedWritePaths.some(allowedPath => normalizedPath.startsWith(allowedPath + '/') ||
468
+ normalizedPath === allowedPath);
469
+ if (isWithinAllowedPath) {
470
+ args.push('--ro-bind', normalizedPath, normalizedPath);
471
+ }
472
+ else {
473
+ logForDebugging(`[Sandbox Linux] Skipping deny path not within allowed paths: ${normalizedPath}`);
474
+ }
475
+ }
476
+ }
477
+ else {
478
+ // No write restrictions: Allow all writes
479
+ args.push('--bind', '/', '/');
480
+ }
481
+ // Handle read restrictions by mounting tmpfs over denied paths
482
+ const readDenyPaths = [...(readConfig?.denyOnly || [])];
483
+ // Always hide /etc/ssh/ssh_config.d to avoid permission issues with OrbStack
484
+ // SSH is very strict about config file permissions and ownership, and they can
485
+ // appear wrong inside the sandbox causing "Bad owner or permissions" errors
486
+ if (fs.existsSync('/etc/ssh/ssh_config.d')) {
487
+ readDenyPaths.push('/etc/ssh/ssh_config.d');
488
+ }
489
+ for (const pathPattern of readDenyPaths) {
490
+ const normalizedPath = normalizePathForSandbox(pathPattern);
491
+ if (!fs.existsSync(normalizedPath)) {
492
+ logForDebugging(`[Sandbox Linux] Skipping non-existent read deny path: ${normalizedPath}`);
493
+ continue;
494
+ }
495
+ const readDenyStat = fs.statSync(normalizedPath);
496
+ if (readDenyStat.isDirectory()) {
497
+ args.push('--tmpfs', normalizedPath);
498
+ }
499
+ else {
500
+ // For files, bind /dev/null instead of tmpfs
501
+ args.push('--ro-bind', '/dev/null', normalizedPath);
502
+ }
503
+ }
504
+ return args;
505
+ }
506
+ /**
507
+ * Wrap a command with sandbox restrictions on Linux
508
+ *
509
+ * UNIX SOCKET BLOCKING (APPLY-SECCOMP):
510
+ * This implementation uses a custom apply-seccomp binary to block Unix domain socket
511
+ * creation for user commands while allowing network infrastructure:
512
+ *
513
+ * Stage 1: Outer bwrap - Network and filesystem isolation (NO seccomp)
514
+ * - Bubblewrap starts with isolated network namespace (--unshare-net)
515
+ * - Bubblewrap applies PID namespace isolation (--unshare-pid and --proc)
516
+ * - Filesystem restrictions are applied (read-only mounts, bind mounts, etc.)
517
+ * - Socat processes start and connect to Unix socket bridges (can use socket(AF_UNIX, ...))
518
+ *
519
+ * Stage 2: apply-seccomp - Seccomp filter application (ONLY seccomp)
520
+ * - apply-seccomp binary applies seccomp filter via prctl(PR_SET_SECCOMP)
521
+ * - Sets PR_SET_NO_NEW_PRIVS to allow seccomp without root
522
+ * - Execs user command with seccomp active (cannot create new Unix sockets)
523
+ *
524
+ * This solves the conflict between:
525
+ * - Security: Blocking arbitrary Unix socket creation in user commands
526
+ * - Functionality: Network sandboxing requires socat to call socket(AF_UNIX, ...) for bridge connections
527
+ *
528
+ * The seccomp-bpf filter blocks socket(AF_UNIX, ...) syscalls, preventing:
529
+ * - Creating new Unix domain socket file descriptors
530
+ *
531
+ * Security limitations:
532
+ * - Does NOT block operations (bind, connect, sendto, etc.) on inherited Unix socket FDs
533
+ * - Does NOT prevent passing Unix socket FDs via SCM_RIGHTS
534
+ * - For most sandboxing use cases, blocking socket creation is sufficient
535
+ *
536
+ * The filter allows:
537
+ * - All TCP/UDP sockets (AF_INET, AF_INET6) for normal network operations
538
+ * - All other syscalls
539
+ *
540
+ * PLATFORM NOTE:
541
+ * The allowUnixSockets configuration is not path-based on Linux (unlike macOS)
542
+ * because seccomp-bpf cannot inspect user-space memory to read socket paths.
543
+ *
544
+ * Requirements for seccomp filtering:
545
+ * - Pre-built apply-seccomp binaries are included for x64 and ARM64
546
+ * - Pre-generated BPF filters are included for x64 and ARM64
547
+ * - Other architectures are not currently supported (no apply-seccomp binary available)
548
+ * - To use sandboxing without Unix socket blocking on unsupported architectures,
549
+ * set allowAllUnixSockets: true in your configuration
550
+ * Dependencies are checked by hasLinuxSandboxDependenciesSync() before enabling the sandbox.
551
+ */
552
+ export async function wrapCommandWithSandboxLinux(params) {
553
+ const { command, needsNetworkRestriction, httpSocketPath, socksSocketPath, httpProxyPort, socksProxyPort, readConfig, writeConfig, enableWeakerNestedSandbox, allowAllUnixSockets, binShell, ripgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, seccompConfig, abortSignal, } = params;
554
+ // Determine if we have restrictions to apply
555
+ // Read: denyOnly pattern - empty array means no restrictions
556
+ // Write: allowOnly pattern - undefined means no restrictions, any config means restrictions
557
+ const hasReadRestrictions = readConfig && readConfig.denyOnly.length > 0;
558
+ const hasWriteRestrictions = writeConfig !== undefined;
559
+ // Check if we need any sandboxing
560
+ if (!needsNetworkRestriction &&
561
+ !hasReadRestrictions &&
562
+ !hasWriteRestrictions) {
563
+ return command;
564
+ }
565
+ const bwrapArgs = ['--new-session', '--die-with-parent'];
566
+ let seccompFilterPath = undefined;
567
+ try {
568
+ // ========== SECCOMP FILTER (Unix Socket Blocking) ==========
569
+ // Use bwrap's --seccomp flag to apply BPF filter that blocks Unix socket creation
570
+ //
571
+ // NOTE: Seccomp filtering is only enabled when allowAllUnixSockets is false
572
+ // (when true, Unix sockets are allowed)
573
+ if (!allowAllUnixSockets) {
574
+ seccompFilterPath =
575
+ generateSeccompFilter(seccompConfig?.bpfPath) ?? undefined;
576
+ if (!seccompFilterPath) {
577
+ // Seccomp not available - log warning and continue without it
578
+ // This provides graceful degradation on systems without seccomp binaries
579
+ logForDebugging('[Sandbox Linux] Seccomp filter not available (missing binaries). ' +
580
+ 'Continuing without Unix socket blocking - sandbox will still provide ' +
581
+ 'filesystem and network isolation but Unix sockets will be allowed.', { level: 'warn' });
582
+ }
583
+ else {
584
+ // Track filter for cleanup and register exit handler
585
+ // Only track runtime-generated filters (not pre-generated ones from vendor/)
586
+ if (!seccompFilterPath.includes('/vendor/seccomp/')) {
587
+ generatedSeccompFilters.add(seccompFilterPath);
588
+ registerSeccompCleanupHandler();
589
+ }
590
+ logForDebugging('[Sandbox Linux] Generated seccomp BPF filter for Unix socket blocking');
591
+ }
592
+ }
593
+ else if (allowAllUnixSockets) {
594
+ logForDebugging('[Sandbox Linux] Skipping seccomp filter - allowAllUnixSockets is enabled');
595
+ }
596
+ // ========== NETWORK RESTRICTIONS ==========
597
+ if (needsNetworkRestriction) {
598
+ // Always unshare network namespace to isolate network access
599
+ // This removes all network interfaces, effectively blocking all network
600
+ bwrapArgs.push('--unshare-net');
601
+ // If proxy sockets are provided, bind them into the sandbox to allow
602
+ // filtered network access through the proxy. If not provided, network
603
+ // is completely blocked (empty allowedDomains = block all)
604
+ if (httpSocketPath && socksSocketPath) {
605
+ // Verify socket files still exist before trying to bind them
606
+ if (!fs.existsSync(httpSocketPath)) {
607
+ throw new Error(`Linux HTTP bridge socket does not exist: ${httpSocketPath}. ` +
608
+ 'The bridge process may have died. Try reinitializing the sandbox.');
609
+ }
610
+ if (!fs.existsSync(socksSocketPath)) {
611
+ throw new Error(`Linux SOCKS bridge socket does not exist: ${socksSocketPath}. ` +
612
+ 'The bridge process may have died. Try reinitializing the sandbox.');
613
+ }
614
+ // Bind both sockets into the sandbox
615
+ bwrapArgs.push('--bind', httpSocketPath, httpSocketPath);
616
+ bwrapArgs.push('--bind', socksSocketPath, socksSocketPath);
617
+ // Add proxy environment variables
618
+ // HTTP_PROXY points to the socat listener inside the sandbox (port 3128)
619
+ // which forwards to the Unix socket that bridges to the host's proxy server
620
+ const proxyEnv = generateProxyEnvVars(3128, // Internal HTTP listener port
621
+ 1080);
622
+ bwrapArgs.push(...proxyEnv.flatMap((env) => {
623
+ const firstEq = env.indexOf('=');
624
+ const key = env.slice(0, firstEq);
625
+ const value = env.slice(firstEq + 1);
626
+ return ['--setenv', key, value];
627
+ }));
628
+ // Add host proxy port environment variables for debugging/transparency
629
+ // These show which host ports the Unix socket bridges connect to
630
+ if (httpProxyPort !== undefined) {
631
+ bwrapArgs.push('--setenv', 'CLAUDE_CODE_HOST_HTTP_PROXY_PORT', String(httpProxyPort));
632
+ }
633
+ if (socksProxyPort !== undefined) {
634
+ bwrapArgs.push('--setenv', 'CLAUDE_CODE_HOST_SOCKS_PROXY_PORT', String(socksProxyPort));
635
+ }
636
+ }
637
+ // If no sockets provided, network is completely blocked (--unshare-net without proxy)
638
+ }
639
+ // ========== FILESYSTEM RESTRICTIONS ==========
640
+ const fsArgs = await generateFilesystemArgs(readConfig, writeConfig, ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal);
641
+ bwrapArgs.push(...fsArgs);
642
+ // Always bind /dev
643
+ bwrapArgs.push('--dev', '/dev');
644
+ // ========== PID NAMESPACE ISOLATION ==========
645
+ // IMPORTANT: These must come AFTER filesystem binds for nested bwrap to work
646
+ // By default, always unshare PID namespace and mount fresh /proc.
647
+ // If we don't have --unshare-pid, it is possible to escape the sandbox.
648
+ // If we don't have --proc, it is possible to read host /proc and leak information about code running
649
+ // outside the sandbox. But, --proc is not available when running in unprivileged docker containers
650
+ // so we support running without it if explicitly requested.
651
+ bwrapArgs.push('--unshare-pid');
652
+ if (!enableWeakerNestedSandbox) {
653
+ // Mount fresh /proc if PID namespace is isolated (secure mode)
654
+ bwrapArgs.push('--proc', '/proc');
655
+ }
656
+ // ========== COMMAND ==========
657
+ // Use the user's shell (zsh, bash, etc.) to ensure aliases/snapshots work
658
+ // Resolve the full path to the shell binary since bwrap doesn't use $PATH
659
+ const shellName = binShell || 'bash';
660
+ const shellPathResult = spawnSync('which', [shellName], {
661
+ encoding: 'utf8',
662
+ });
663
+ if (shellPathResult.status !== 0) {
664
+ throw new Error(`Shell '${shellName}' not found in PATH`);
665
+ }
666
+ const shell = shellPathResult.stdout.trim();
667
+ bwrapArgs.push('--', shell, '-c');
668
+ // If we have network restrictions, use the network bridge setup with apply-seccomp for seccomp
669
+ // Otherwise, just run the command directly with apply-seccomp if needed
670
+ if (needsNetworkRestriction && httpSocketPath && socksSocketPath) {
671
+ // Pass seccomp filter to buildSandboxCommand for apply-seccomp application
672
+ // This allows socat to start before seccomp is applied
673
+ const sandboxCommand = buildSandboxCommand(httpSocketPath, socksSocketPath, command, seccompFilterPath, shell, seccompConfig?.applyPath);
674
+ bwrapArgs.push(sandboxCommand);
675
+ }
676
+ else if (seccompFilterPath) {
677
+ // No network restrictions but we have seccomp - use apply-seccomp directly
678
+ // apply-seccomp is a simple C program that applies the seccomp filter and execs the command
679
+ const applySeccompBinary = getApplySeccompBinaryPath(seccompConfig?.applyPath);
680
+ if (!applySeccompBinary) {
681
+ throw new Error('apply-seccomp binary not found. This should have been caught earlier. ' +
682
+ 'Ensure vendor/seccomp/{x64,arm64}/apply-seccomp binaries are included in the package.');
683
+ }
684
+ const applySeccompCmd = shellquote.quote([
685
+ applySeccompBinary,
686
+ seccompFilterPath,
687
+ shell,
688
+ '-c',
689
+ command,
690
+ ]);
691
+ bwrapArgs.push(applySeccompCmd);
692
+ }
693
+ else {
694
+ bwrapArgs.push(command);
695
+ }
696
+ // Build the outer bwrap command
697
+ const wrappedCommand = shellquote.quote(['bwrap', ...bwrapArgs]);
698
+ const restrictions = [];
699
+ if (needsNetworkRestriction)
700
+ restrictions.push('network');
701
+ if (hasReadRestrictions || hasWriteRestrictions)
702
+ restrictions.push('filesystem');
703
+ if (seccompFilterPath)
704
+ restrictions.push('seccomp(unix-block)');
705
+ logForDebugging(`[Sandbox Linux] Wrapped command with bwrap (${restrictions.join(', ')} restrictions)`);
706
+ return wrappedCommand;
707
+ }
708
+ catch (error) {
709
+ // Clean up seccomp filter on error
710
+ if (seccompFilterPath && !seccompFilterPath.includes('/vendor/seccomp/')) {
711
+ generatedSeccompFilters.delete(seccompFilterPath);
712
+ try {
713
+ cleanupSeccompFilter(seccompFilterPath);
714
+ }
715
+ catch (cleanupError) {
716
+ logForDebugging(`[Sandbox Linux] Failed to clean up seccomp filter on error: ${cleanupError}`, { level: 'error' });
717
+ }
718
+ }
719
+ // Re-throw the original error
720
+ throw error;
721
+ }
722
+ }
723
+ //# sourceMappingURL=linux-sandbox-utils.js.map