@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +17 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +158 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox/generate-seccomp-filter.d.ts +65 -0
- package/dist/sandbox/generate-seccomp-filter.d.ts.map +1 -0
- package/dist/sandbox/generate-seccomp-filter.js +185 -0
- package/dist/sandbox/generate-seccomp-filter.js.map +1 -0
- package/dist/sandbox/http-proxy.d.ts +14 -0
- package/dist/sandbox/http-proxy.d.ts.map +1 -0
- package/dist/sandbox/http-proxy.js +238 -0
- package/dist/sandbox/http-proxy.js.map +1 -0
- package/dist/sandbox/linux-sandbox-utils.d.ts +121 -0
- package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -0
- package/dist/sandbox/linux-sandbox-utils.js +723 -0
- package/dist/sandbox/linux-sandbox-utils.js.map +1 -0
- package/dist/sandbox/macos-sandbox-utils.d.ts +57 -0
- package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -0
- package/dist/sandbox/macos-sandbox-utils.js +611 -0
- package/dist/sandbox/macos-sandbox-utils.js.map +1 -0
- package/dist/sandbox/observability.d.ts +56 -0
- package/dist/sandbox/observability.d.ts.map +1 -0
- package/dist/sandbox/observability.js +140 -0
- package/dist/sandbox/observability.js.map +1 -0
- package/dist/sandbox/sandbox-config.d.ts +277 -0
- package/dist/sandbox/sandbox-config.d.ts.map +1 -0
- package/dist/sandbox/sandbox-config.js +166 -0
- package/dist/sandbox/sandbox-config.js.map +1 -0
- package/dist/sandbox/sandbox-manager.d.ts +50 -0
- package/dist/sandbox/sandbox-manager.d.ts.map +1 -0
- package/dist/sandbox/sandbox-manager.js +816 -0
- package/dist/sandbox/sandbox-manager.js.map +1 -0
- package/dist/sandbox/sandbox-schemas.d.ts +53 -0
- package/dist/sandbox/sandbox-schemas.d.ts.map +1 -0
- package/dist/sandbox/sandbox-schemas.js +3 -0
- package/dist/sandbox/sandbox-schemas.js.map +1 -0
- package/dist/sandbox/sandbox-utils.d.ts +83 -0
- package/dist/sandbox/sandbox-utils.d.ts.map +1 -0
- package/dist/sandbox/sandbox-utils.js +343 -0
- package/dist/sandbox/sandbox-utils.js.map +1 -0
- package/dist/sandbox/sandbox-violation-store.d.ts +19 -0
- package/dist/sandbox/sandbox-violation-store.d.ts.map +1 -0
- package/dist/sandbox/sandbox-violation-store.js +54 -0
- package/dist/sandbox/sandbox-violation-store.js.map +1 -0
- package/dist/sandbox/socks-proxy.d.ts +14 -0
- package/dist/sandbox/socks-proxy.d.ts.map +1 -0
- package/dist/sandbox/socks-proxy.js +109 -0
- package/dist/sandbox/socks-proxy.js.map +1 -0
- package/dist/utils/config-loader.d.ts +11 -0
- package/dist/utils/config-loader.d.ts.map +1 -0
- package/dist/utils/config-loader.js +60 -0
- package/dist/utils/config-loader.js.map +1 -0
- package/dist/utils/debug.d.ts +7 -0
- package/dist/utils/debug.d.ts.map +1 -0
- package/dist/utils/debug.js +25 -0
- package/dist/utils/debug.js.map +1 -0
- package/dist/utils/platform.d.ts +15 -0
- package/dist/utils/platform.d.ts.map +1 -0
- package/dist/utils/platform.js +49 -0
- package/dist/utils/platform.js.map +1 -0
- package/dist/utils/ripgrep.d.ts +20 -0
- package/dist/utils/ripgrep.d.ts.map +1 -0
- package/dist/utils/ripgrep.js +51 -0
- package/dist/utils/ripgrep.js.map +1 -0
- package/dist/vendor/seccomp/arm64/apply-seccomp +0 -0
- package/dist/vendor/seccomp/arm64/unix-block.bpf +0 -0
- package/dist/vendor/seccomp/x64/apply-seccomp +0 -0
- package/dist/vendor/seccomp/x64/unix-block.bpf +0 -0
- package/dist/vendor/seccomp-src/apply-seccomp.c +98 -0
- package/dist/vendor/seccomp-src/seccomp-unix-block.c +97 -0
- package/package.json +90 -0
- package/vendor/seccomp/arm64/apply-seccomp +0 -0
- package/vendor/seccomp/arm64/unix-block.bpf +0 -0
- package/vendor/seccomp/x64/apply-seccomp +0 -0
- package/vendor/seccomp/x64/unix-block.bpf +0 -0
- package/vendor/seccomp-src/apply-seccomp.c +98 -0
- 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
|