@sysid/sandbox-runtime-improved 0.0.56-sysid.1 → 0.0.64-sysid.1
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/README.md +106 -4
- package/dist/cli.js +12 -18
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/sandbox/credential-mask-files.d.ts +157 -0
- package/dist/sandbox/credential-mask-files.d.ts.map +1 -0
- package/dist/sandbox/credential-mask-files.js +298 -0
- package/dist/sandbox/credential-mask-files.js.map +1 -0
- package/dist/sandbox/credential-sentinel.d.ts +72 -0
- package/dist/sandbox/credential-sentinel.d.ts.map +1 -0
- package/dist/sandbox/credential-sentinel.js +130 -0
- package/dist/sandbox/credential-sentinel.js.map +1 -0
- package/dist/sandbox/domain-pattern.d.ts +36 -0
- package/dist/sandbox/domain-pattern.d.ts.map +1 -0
- package/dist/sandbox/domain-pattern.js +60 -0
- package/dist/sandbox/domain-pattern.js.map +1 -0
- package/dist/sandbox/http-proxy.d.ts +33 -2
- package/dist/sandbox/http-proxy.d.ts.map +1 -1
- package/dist/sandbox/http-proxy.js +30 -2
- package/dist/sandbox/http-proxy.js.map +1 -1
- package/dist/sandbox/linux-sandbox-utils.d.ts +20 -0
- package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/linux-sandbox-utils.js +134 -65
- package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
- package/dist/sandbox/linux-violation-monitor.d.ts +48 -0
- package/dist/sandbox/linux-violation-monitor.d.ts.map +1 -0
- package/dist/sandbox/linux-violation-monitor.js +156 -0
- package/dist/sandbox/linux-violation-monitor.js.map +1 -0
- package/dist/sandbox/listen-in-range.d.ts +12 -0
- package/dist/sandbox/listen-in-range.d.ts.map +1 -0
- package/dist/sandbox/listen-in-range.js +43 -0
- package/dist/sandbox/listen-in-range.js.map +1 -0
- package/dist/sandbox/macos-sandbox-utils.d.ts +11 -0
- package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/macos-sandbox-utils.js +72 -17
- package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
- package/dist/sandbox/mitm-ca.d.ts +69 -2
- package/dist/sandbox/mitm-ca.d.ts.map +1 -1
- package/dist/sandbox/mitm-ca.js +188 -16
- package/dist/sandbox/mitm-ca.js.map +1 -1
- package/dist/sandbox/mitm-leaf.d.ts +1 -1
- package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
- package/dist/sandbox/mitm-leaf.js +26 -19
- package/dist/sandbox/mitm-leaf.js.map +1 -1
- package/dist/sandbox/mux-proxy.d.ts +59 -0
- package/dist/sandbox/mux-proxy.d.ts.map +1 -0
- package/dist/sandbox/mux-proxy.js +159 -0
- package/dist/sandbox/mux-proxy.js.map +1 -0
- package/dist/sandbox/request-filter.d.ts +11 -1
- package/dist/sandbox/request-filter.d.ts.map +1 -1
- package/dist/sandbox/request-filter.js.map +1 -1
- package/dist/sandbox/sandbox-config.d.ts +461 -41
- package/dist/sandbox/sandbox-config.d.ts.map +1 -1
- package/dist/sandbox/sandbox-config.js +342 -26
- package/dist/sandbox/sandbox-config.js.map +1 -1
- package/dist/sandbox/sandbox-manager.d.ts +5 -1
- package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
- package/dist/sandbox/sandbox-manager.js +697 -211
- package/dist/sandbox/sandbox-manager.js.map +1 -1
- package/dist/sandbox/sandbox-schemas.d.ts +15 -0
- package/dist/sandbox/sandbox-schemas.d.ts.map +1 -1
- package/dist/sandbox/sandbox-utils.d.ts +55 -6
- package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/sandbox-utils.js +143 -30
- package/dist/sandbox/sandbox-utils.js.map +1 -1
- package/dist/sandbox/socks-proxy.d.ts +11 -5
- package/dist/sandbox/socks-proxy.d.ts.map +1 -1
- package/dist/sandbox/socks-proxy.js +13 -81
- package/dist/sandbox/socks-proxy.js.map +1 -1
- package/dist/sandbox/tls-terminate-proxy.d.ts +2 -2
- package/dist/sandbox/tls-terminate-proxy.d.ts.map +1 -1
- package/dist/sandbox/tls-terminate-proxy.js +31 -5
- package/dist/sandbox/tls-terminate-proxy.js.map +1 -1
- package/dist/sandbox/windows-sandbox-utils.d.ts +508 -116
- package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/windows-sandbox-utils.js +721 -209
- package/dist/sandbox/windows-sandbox-utils.js.map +1 -1
- package/dist/utils/shell-quote.d.ts +27 -0
- package/dist/utils/shell-quote.d.ts.map +1 -0
- package/dist/utils/shell-quote.js +47 -0
- package/dist/utils/shell-quote.js.map +1 -0
- package/package.json +7 -6
- package/vendor/seccomp/arm64/apply-seccomp +0 -0
- package/vendor/seccomp/build.ts +8 -30
- package/vendor/seccomp/x64/apply-seccomp +0 -0
- package/vendor/srt-win/build.ts +21 -0
- package/dist/vendor/seccomp/Dockerfile.build +0 -6
- package/dist/vendor/seccomp/arm64/apply-seccomp +0 -0
- package/dist/vendor/seccomp/build.ts +0 -91
- package/dist/vendor/seccomp/x64/apply-seccomp +0 -0
- package/dist/vendor/seccomp-src/apply-seccomp.c +0 -280
- package/dist/vendor/seccomp-src/seccomp-unix-block.c +0 -148
- package/dist/vendor/srt-win/Cargo.lock +0 -361
- package/dist/vendor/srt-win/Cargo.toml +0 -46
- package/dist/vendor/srt-win/ci/cleanup.ps1 +0 -49
- package/dist/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
- package/dist/vendor/srt-win/ci/smoke.ps1 +0 -307
- package/dist/vendor/srt-win/src/job.rs +0 -102
- package/dist/vendor/srt-win/src/launch.rs +0 -732
- package/dist/vendor/srt-win/src/lib.rs +0 -20
- package/dist/vendor/srt-win/src/main.rs +0 -669
- package/dist/vendor/srt-win/src/self_protect.rs +0 -169
- package/dist/vendor/srt-win/src/sid.rs +0 -296
- package/dist/vendor/srt-win/src/token.rs +0 -341
- package/dist/vendor/srt-win/src/util.rs +0 -42
- package/dist/vendor/srt-win/src/wfp.rs +0 -992
- package/dist/vendor/srt-win/src/winsta.rs +0 -209
- package/dist/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
- package/vendor/seccomp-src/apply-seccomp.c +0 -280
- package/vendor/seccomp-src/seccomp-unix-block.c +0 -148
- package/vendor/srt-win/Cargo.lock +0 -361
- package/vendor/srt-win/Cargo.toml +0 -46
- package/vendor/srt-win/ci/cleanup.ps1 +0 -49
- package/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
- package/vendor/srt-win/ci/smoke.ps1 +0 -307
- package/vendor/srt-win/src/job.rs +0 -102
- package/vendor/srt-win/src/launch.rs +0 -732
- package/vendor/srt-win/src/lib.rs +0 -20
- package/vendor/srt-win/src/main.rs +0 -669
- package/vendor/srt-win/src/self_protect.rs +0 -169
- package/vendor/srt-win/src/sid.rs +0 -296
- package/vendor/srt-win/src/token.rs +0 -341
- package/vendor/srt-win/src/util.rs +0 -42
- package/vendor/srt-win/src/wfp.rs +0 -992
- package/vendor/srt-win/src/winsta.rs +0 -209
- package/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
|
@@ -1,36 +1,73 @@
|
|
|
1
1
|
import { createHttpProxyServer } from './http-proxy.js';
|
|
2
2
|
import { createSocksProxyServer } from './socks-proxy.js';
|
|
3
|
-
import {
|
|
3
|
+
import { createMuxProxyServer } from './mux-proxy.js';
|
|
4
|
+
import { listenInRange } from './listen-in-range.js';
|
|
5
|
+
import { SentinelRegistry } from './credential-sentinel.js';
|
|
6
|
+
import { MaskedFileStore, buildMaskedFileBinds, } from './credential-mask-files.js';
|
|
7
|
+
import { createMitmCA, CRL_PATH, disposeMitmCA, } from './mitm-ca.js';
|
|
4
8
|
import { logForDebugging } from '../utils/debug.js';
|
|
5
9
|
import { whichSync } from '../utils/which.js';
|
|
6
10
|
import { getPlatform, getWslVersion } from '../utils/platform.js';
|
|
7
11
|
import * as fs from 'fs';
|
|
8
|
-
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { randomBytes, X509Certificate } from 'node:crypto';
|
|
9
13
|
import { wrapCommandWithSandboxLinux, initializeLinuxNetworkBridge, checkLinuxDependencies, cleanupBwrapMountPoints, } from './linux-sandbox-utils.js';
|
|
10
14
|
import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, } from './macos-sandbox-utils.js';
|
|
11
|
-
import {
|
|
15
|
+
import { startLinuxSandboxViolationMonitor, } from './linux-violation-monitor.js';
|
|
16
|
+
import { checkWindowsDependencies, wrapCommandWithSandboxWindows, parseWindowsBinShell, expandWindowsFsPaths, stampWindowsAcl, restoreWindowsAcl, grantWindowsAcl, revokeWindowsAcl, getWindowsSandboxUserStatus, getWindowsSandboxCaCert, verifyWindowsWfpEgress, resolveSrtWin, DEFAULT_WINDOWS_PROXY_PORT_RANGE, } from './windows-sandbox-utils.js';
|
|
12
17
|
import { getDefaultWritePaths, containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, ensureSandboxTmpdir, } from './sandbox-utils.js';
|
|
13
18
|
import { SandboxViolationStore } from './sandbox-violation-store.js';
|
|
14
|
-
import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy,
|
|
15
|
-
import {
|
|
19
|
+
import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy, } from './parent-proxy.js';
|
|
20
|
+
import { matchesDomainPattern } from './domain-pattern.js';
|
|
16
21
|
import { EOL } from 'node:os';
|
|
22
|
+
import { dirname } from 'node:path';
|
|
17
23
|
// ============================================================================
|
|
18
24
|
// Private Module State
|
|
19
25
|
// ============================================================================
|
|
20
26
|
let config;
|
|
21
27
|
let httpProxyServer;
|
|
22
28
|
let socksProxyServer;
|
|
29
|
+
let muxProxyServer;
|
|
23
30
|
let managerContext;
|
|
24
31
|
let initializationPromise;
|
|
25
32
|
let cleanupRegistered = false;
|
|
26
33
|
let logMonitorShutdown;
|
|
34
|
+
let linuxMonitor;
|
|
27
35
|
let parentProxy;
|
|
28
36
|
let mitmCA;
|
|
29
37
|
// Per-session proxy auth token. Generated at proxy start, exported only into
|
|
30
38
|
// the sandbox child env, checked on every CONNECT/request — so a host process
|
|
31
39
|
// dialing 127.0.0.1:<proxyPort> can't reach the filter callback.
|
|
32
40
|
let proxyAuthToken;
|
|
41
|
+
// Windows: the resolved access set that was actually applied at
|
|
42
|
+
// initialize(). `undefined` means no stamp/grant was applied
|
|
43
|
+
// (gates running `acl restore`/`acl revoke` at reset()).
|
|
44
|
+
let windowsFsStampedSet;
|
|
45
|
+
// The sandbox user SID captured at initialize(). reset() uses this
|
|
46
|
+
// so a config change between init and reset can't strand ACEs
|
|
47
|
+
// under a different SID.
|
|
48
|
+
let windowsFsSbUserSid;
|
|
49
|
+
// The RAW config inputs that produced `windowsFsStampedSet`.
|
|
50
|
+
// updateConfig() compares these (not the resolved set) so it never
|
|
51
|
+
// re-expands globs — see `sameWindowsStampSet`.
|
|
52
|
+
let windowsFsRawInputs;
|
|
53
|
+
// `verifyWindowsWfpEgress()` is once per PROCESS (it spawns a
|
|
54
|
+
// CreateProcessWithLogonW runner; first call may create the sandbox
|
|
55
|
+
// user's profile). The WFP fence is install-scoped, not config- or
|
|
56
|
+
// session-scoped — reset() does NOT clear this, so updateConfig()'s
|
|
57
|
+
// reset+reinit and the test suite's per-test reset() don't re-verify.
|
|
58
|
+
let windowsWfpVerified = false;
|
|
59
|
+
// Resolved once at initialize() (`resolveSrtWin` stats the disk).
|
|
60
|
+
// Captured so wrapWithSandboxArgv/reset() don't re-resolve per call
|
|
61
|
+
// and so reset()'s revoke/restore addresses the SAME binary the
|
|
62
|
+
// grants/stamps were applied with even if `config` mutated between.
|
|
63
|
+
let srtWinSpawn;
|
|
33
64
|
const sandboxViolationStore = new SandboxViolationStore();
|
|
65
|
+
// Per-session sentinel↔real-value map for masked credentials. Lives only in
|
|
66
|
+
// process memory; never written to disk or logged. Cleared on reset().
|
|
67
|
+
const sentinelRegistry = new SentinelRegistry();
|
|
68
|
+
// Temp dir holding the sentinel-content fake files for masked credential
|
|
69
|
+
// files. Created lazily on first masked file; removed on reset().
|
|
70
|
+
const maskedFileStore = new MaskedFileStore();
|
|
34
71
|
// ============================================================================
|
|
35
72
|
// Private Helper Functions (not exported)
|
|
36
73
|
// ============================================================================
|
|
@@ -48,26 +85,6 @@ function registerCleanup() {
|
|
|
48
85
|
process.once('SIGTERM', cleanupHandler);
|
|
49
86
|
cleanupRegistered = true;
|
|
50
87
|
}
|
|
51
|
-
function matchesDomainPattern(hostname, pattern) {
|
|
52
|
-
const h = hostname.toLowerCase();
|
|
53
|
-
// Bare '*' is deny-all when it appears in deniedDomains. The schema only
|
|
54
|
-
// accepts it there (allowedDomains still rejects it as too broad).
|
|
55
|
-
if (pattern === '*')
|
|
56
|
-
return true;
|
|
57
|
-
// Support wildcard patterns like *.example.com. Never apply wildcard
|
|
58
|
-
// suffix matching to IP literals — an IPv6 zone-ID payload like
|
|
59
|
-
// `::ffff:1.2.3.4%x.allowed.com` would otherwise pass .endsWith() while
|
|
60
|
-
// the OS connects to the bare IP. isValidHost already rejects `%`, but
|
|
61
|
-
// we refuse here too for defence in depth.
|
|
62
|
-
if (pattern.startsWith('*.')) {
|
|
63
|
-
if (isIP(stripBrackets(h)))
|
|
64
|
-
return false;
|
|
65
|
-
const baseDomain = pattern.substring(2).toLowerCase();
|
|
66
|
-
return h.endsWith('.' + baseDomain);
|
|
67
|
-
}
|
|
68
|
-
// Exact match for non-wildcard patterns
|
|
69
|
-
return h === pattern.toLowerCase();
|
|
70
|
-
}
|
|
71
88
|
async function filterNetworkRequest(port, host, sandboxAskCallback) {
|
|
72
89
|
if (!config) {
|
|
73
90
|
logForDebugging('No config available, denying network request');
|
|
@@ -133,6 +150,24 @@ async function filterNetworkRequest(port, host, sandboxAskCallback) {
|
|
|
133
150
|
* Returns the socket path if the host matches any MITM domain pattern,
|
|
134
151
|
* otherwise returns undefined.
|
|
135
152
|
*/
|
|
153
|
+
/**
|
|
154
|
+
* Build the header-mutation callback that substitutes sentinel→real for
|
|
155
|
+
* masked credentials. Returns undefined when no `credentials` block is
|
|
156
|
+
* configured — wiring the seam at all is unnecessary then.
|
|
157
|
+
*
|
|
158
|
+
* Per-host gating happens inside the registry: each sentinel carries its
|
|
159
|
+
* own injectHosts list and substitutes independently, so credential A's
|
|
160
|
+
* sentinel cannot be laundered through credential B's allowed host. The
|
|
161
|
+
* returned closure does not log header values; the registry holds the only
|
|
162
|
+
* copy of the real value.
|
|
163
|
+
*/
|
|
164
|
+
function buildCredentialInjector() {
|
|
165
|
+
if (!config?.credentials)
|
|
166
|
+
return undefined;
|
|
167
|
+
return (headers, destHost) => {
|
|
168
|
+
sentinelRegistry.substituteInHeaders(headers, destHost, matchesDomainPattern);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
136
171
|
function getMitmSocketPath(host) {
|
|
137
172
|
if (!config?.network.mitmProxy) {
|
|
138
173
|
return undefined;
|
|
@@ -147,101 +182,82 @@ function getMitmSocketPath(host) {
|
|
|
147
182
|
return undefined;
|
|
148
183
|
}
|
|
149
184
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
185
|
+
* Per-host TLS-termination opt-out from network.tlsTerminate.excludeDomains.
|
|
186
|
+
* Only consulted by the HTTP proxy when tlsTerminate is enabled; exempted
|
|
187
|
+
* hosts fall back to the opaque CONNECT tunnel (still allowlist-filtered),
|
|
188
|
+
* so mTLS / cert-pinning clients can complete their own handshake.
|
|
153
189
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* port we landed on, so ephemeral is fine.
|
|
190
|
+
* Matches the canonicalized hostname, like the allow/deny filter
|
|
191
|
+
* (filterNetworkRequest) — otherwise a spelling the allowlist accepts after
|
|
192
|
+
* canonicalization (`127.1`, a trailing-dot FQDN) would dodge the exclusion
|
|
193
|
+
* and get terminated anyway.
|
|
159
194
|
*/
|
|
160
|
-
function
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
reject(err ?? new Error('listen error'));
|
|
187
|
-
};
|
|
188
|
-
server.once('error', onError);
|
|
189
|
-
server.once('listening', onListening);
|
|
190
|
-
doListen(range ? port : 0);
|
|
191
|
-
};
|
|
192
|
-
tryNext();
|
|
193
|
-
});
|
|
195
|
+
function shouldTerminateTLSForHost(host) {
|
|
196
|
+
const excludeDomains = config?.network.tlsTerminate?.excludeDomains;
|
|
197
|
+
if (!excludeDomains?.length)
|
|
198
|
+
return true;
|
|
199
|
+
const canonicalHost = canonicalizeHost(host) ?? host;
|
|
200
|
+
for (const pattern of excludeDomains) {
|
|
201
|
+
if (!matchesDomainPattern(canonicalHost, pattern))
|
|
202
|
+
continue;
|
|
203
|
+
logForDebugging(`Host ${host} matches tlsTerminate.excludeDomains pattern ${pattern}; skipping TLS termination`);
|
|
204
|
+
// Masked-credential substitution only happens on the terminated path,
|
|
205
|
+
// so a credential whose injectHosts cover this host can never be
|
|
206
|
+
// injected here — the upstream gets the placeholder. Config validation
|
|
207
|
+
// rejects the fully-contradictory spellings; this flags the partial
|
|
208
|
+
// ones (e.g. default injectHosts = allowedDomains) at the moment they
|
|
209
|
+
// actually bite.
|
|
210
|
+
const masked = sentinelRegistry.namesInjectableAt(canonicalHost, matchesDomainPattern);
|
|
211
|
+
if (masked.length > 0) {
|
|
212
|
+
logForDebugging(`tlsTerminate.excludeDomains: masked credential(s) ${masked.join(', ')} ` +
|
|
213
|
+
`are configured for injection at ${host}, but its connections are ` +
|
|
214
|
+
`not terminated, so the upstream will receive the placeholder`, { level: 'error' });
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
194
219
|
}
|
|
195
|
-
async function
|
|
220
|
+
async function startMuxProxyServer(sandboxAskCallback, portRange) {
|
|
221
|
+
const injectCredentials = buildCredentialInjector();
|
|
196
222
|
httpProxyServer = createHttpProxyServer({
|
|
197
223
|
filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
|
|
198
224
|
getMitmSocketPath,
|
|
199
225
|
mitmCA,
|
|
226
|
+
shouldTerminateTLS: shouldTerminateTLSForHost,
|
|
200
227
|
filterRequest: config?.network.filterRequest,
|
|
228
|
+
// TLS-terminated path always gets the injector; the plain-HTTP path
|
|
229
|
+
// only when explicitly opted in. Without the opt-in, a sentinel sent
|
|
230
|
+
// over plain HTTP reaches the upstream unchanged (fails closed).
|
|
231
|
+
mutateHeaders: injectCredentials,
|
|
232
|
+
mutateHeadersPlaintext: config?.credentials?.allowPlaintextInject
|
|
233
|
+
? injectCredentials
|
|
234
|
+
: undefined,
|
|
201
235
|
parentProxy,
|
|
202
236
|
proxyAuthToken,
|
|
203
237
|
});
|
|
204
|
-
const server = httpProxyServer;
|
|
205
|
-
await listenInRange(server, p => server.listen(p, '127.0.0.1'), portRange, excludePorts);
|
|
206
|
-
const address = server.address();
|
|
207
|
-
if (!address || typeof address !== 'object') {
|
|
208
|
-
throw new Error('Failed to get HTTP proxy server address');
|
|
209
|
-
}
|
|
210
|
-
server.unref();
|
|
211
|
-
logForDebugging(`HTTP proxy listening on localhost:${address.port}`);
|
|
212
|
-
return address.port;
|
|
213
|
-
}
|
|
214
|
-
async function startSocksProxyServer(sandboxAskCallback, portRange, excludePorts) {
|
|
215
238
|
socksProxyServer = createSocksProxyServer({
|
|
216
239
|
filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
|
|
217
240
|
parentProxy,
|
|
218
241
|
proxyAuthToken,
|
|
219
242
|
});
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const port = await wrapper.listen(p, '127.0.0.1');
|
|
235
|
-
wrapper.unref();
|
|
236
|
-
return port;
|
|
237
|
-
}
|
|
238
|
-
catch (err) {
|
|
239
|
-
lastErr = err;
|
|
240
|
-
if (err?.code !== 'EADDRINUSE')
|
|
241
|
-
throw err;
|
|
242
|
-
}
|
|
243
|
+
muxProxyServer = createMuxProxyServer({
|
|
244
|
+
httpServer: httpProxyServer,
|
|
245
|
+
handleSocksConnection: s => socksProxyServer.handleConnection(s),
|
|
246
|
+
httpBackendPortRange: portRange,
|
|
247
|
+
});
|
|
248
|
+
const mux = muxProxyServer;
|
|
249
|
+
// Backend first so the front-end never accepts a connection that would
|
|
250
|
+
// dispatch to an unbound backend. On Windows the backend's port is
|
|
251
|
+
// excluded when binding the front-end in the same WFP range.
|
|
252
|
+
const backendPort = await mux.listenHttpBackend();
|
|
253
|
+
await listenInRange(mux.server, p => mux.server.listen(p, '127.0.0.1'), portRange, backendPort !== undefined ? new Set([backendPort]) : new Set());
|
|
254
|
+
const muxPort = mux.getPort();
|
|
255
|
+
if (muxPort === undefined) {
|
|
256
|
+
throw new Error('Failed to get mux proxy server port');
|
|
243
257
|
}
|
|
244
|
-
|
|
258
|
+
mux.unref();
|
|
259
|
+
logForDebugging(`Mux proxy (HTTP+SOCKS) listening on localhost:${muxPort}`);
|
|
260
|
+
return muxPort;
|
|
245
261
|
}
|
|
246
262
|
// ============================================================================
|
|
247
263
|
// Public Module Functions (will be exported via namespace)
|
|
@@ -283,8 +299,169 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
|
|
|
283
299
|
logMonitorShutdown = startMacOSSandboxLogMonitor(sandboxViolationStore.addViolation.bind(sandboxViolationStore), config.ignoreViolations);
|
|
284
300
|
logForDebugging('Started macOS sandbox log monitor');
|
|
285
301
|
}
|
|
302
|
+
if (enableLogMonitor && getPlatform() === 'linux') {
|
|
303
|
+
linuxMonitor = startLinuxSandboxViolationMonitor(sandboxViolationStore.addViolation.bind(sandboxViolationStore), {
|
|
304
|
+
// apply-seccomp's observer reports every write-intent syscall
|
|
305
|
+
// (allowed or not). Only paths bwrap would actually refuse — outside
|
|
306
|
+
// allowWrite or inside a denyWrite carve-out — go to the store.
|
|
307
|
+
allowWritePaths: [
|
|
308
|
+
...getDefaultWritePaths(),
|
|
309
|
+
...config.filesystem.allowWrite,
|
|
310
|
+
],
|
|
311
|
+
denyWritePaths: config.filesystem.denyWrite,
|
|
312
|
+
ignoreViolations: config.ignoreViolations,
|
|
313
|
+
});
|
|
314
|
+
// Don't block initialization on listen() — wrap-time checks
|
|
315
|
+
// fs.existsSync(observeSocketPath) and degrades gracefully.
|
|
316
|
+
void linuxMonitor.ready;
|
|
317
|
+
logForDebugging('Started Linux seccomp violation monitor');
|
|
318
|
+
}
|
|
286
319
|
// Register cleanup handlers first time
|
|
287
320
|
registerCleanup();
|
|
321
|
+
// Windows: validate provisioning + filesystem config BEFORE any
|
|
322
|
+
// sandboxed child can be spawned. Doing this at initialize() (not
|
|
323
|
+
// wrap-time) means the host gets a single actionable error before
|
|
324
|
+
// any per-exec work happens, instead of exit-15 on every command.
|
|
325
|
+
if (getPlatform() === 'windows') {
|
|
326
|
+
// Resolve once (stats disk); captured module-level for wrap/reset.
|
|
327
|
+
srtWinSpawn = resolveSrtWin(runtimeConfig.windows?.srtWin);
|
|
328
|
+
const srtWin = srtWinSpawn;
|
|
329
|
+
const u = getWindowsSandboxUserStatus({ srtWin });
|
|
330
|
+
if (!u.provisioned || !u.credPresent) {
|
|
331
|
+
config = undefined;
|
|
332
|
+
throw new Error(`Windows sandbox user is not provisioned (user=` +
|
|
333
|
+
`${u.provisioned}, cred=${u.credPresent}). Run \`npx ` +
|
|
334
|
+
`sandbox-runtime windows-install\` (one UAC prompt) to ` +
|
|
335
|
+
`provision it.`);
|
|
336
|
+
}
|
|
337
|
+
// Behavioral proof the WFP egress fence is active for the
|
|
338
|
+
// sandbox user — BFE enumeration (`wfp status`) is admin-gated,
|
|
339
|
+
// so this is the non-elevated readiness check. Fails closed: a
|
|
340
|
+
// stale install (user provisioned but filters since removed)
|
|
341
|
+
// throws here instead of running every exec with full egress.
|
|
342
|
+
// After the user-status check so the not-provisioned message is
|
|
343
|
+
// the actionable one. Once per process — the fence is install-
|
|
344
|
+
// scoped, not session-scoped.
|
|
345
|
+
if (!windowsWfpVerified) {
|
|
346
|
+
try {
|
|
347
|
+
await verifyWindowsWfpEgress({
|
|
348
|
+
proxyPortRange: runtimeConfig.windows?.proxyPortRange,
|
|
349
|
+
srtWin,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
config = undefined;
|
|
354
|
+
throw e;
|
|
355
|
+
}
|
|
356
|
+
windowsWfpVerified = true;
|
|
357
|
+
}
|
|
358
|
+
// schannel-level trust under the sandbox user is install-time
|
|
359
|
+
// (cert lifecycle = sandbox-user lifecycle), not per-session.
|
|
360
|
+
// System32 curl / IWR / .NET / default-backend git only trust
|
|
361
|
+
// what's in the sandbox user's `CurrentUser\Root` — which
|
|
362
|
+
// `srt-win exec` does not (and must not) write. Compare
|
|
363
|
+
// thumbprints so a stale install-time CA doesn't pass the gate
|
|
364
|
+
// while schannel rejects the session's proxy-minted leaves.
|
|
365
|
+
if (runtimeConfig.network.tlsTerminate && mitmCA) {
|
|
366
|
+
const installed = getWindowsSandboxCaCert(u);
|
|
367
|
+
const sessionThumb = new X509Certificate(mitmCA.certPem).fingerprint
|
|
368
|
+
.replace(/:/g, '')
|
|
369
|
+
.toUpperCase();
|
|
370
|
+
if (!installed) {
|
|
371
|
+
config = undefined;
|
|
372
|
+
throw new Error(`tlsTerminate on Windows requires the sandbox to be ` +
|
|
373
|
+
`installed with this CA (thumb=${sessionThumb}): run ` +
|
|
374
|
+
`\`srt-win user trust-ca ${mitmCA.certPath}\`. Per-exec ` +
|
|
375
|
+
`installs into the sandbox user's Root store are not ` +
|
|
376
|
+
`supported.`);
|
|
377
|
+
}
|
|
378
|
+
if (installed.thumb !== sessionThumb) {
|
|
379
|
+
config = undefined;
|
|
380
|
+
throw new Error(`tlsTerminate on Windows: the sandbox's installed CA ` +
|
|
381
|
+
`(thumb=${installed.thumb}) doesn't match this ` +
|
|
382
|
+
`session's CA (thumb=${sessionThumb}). Run \`srt-win ` +
|
|
383
|
+
`user trust-ca ${mitmCA.certPath}\` to update it.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Filesystem grants/denies — additive sandbox-user ACEs.
|
|
387
|
+
try {
|
|
388
|
+
const acc = computeWindowsFsAccessSet(runtimeConfig);
|
|
389
|
+
// The trust bundle the CA-trust env vars point at
|
|
390
|
+
// (NODE_EXTRA_CA_CERTS etc.) must be readable by the
|
|
391
|
+
// srt-sandbox child. It's written into the broker's %TEMP%,
|
|
392
|
+
// which the sandbox user has no inherent rights on, so it
|
|
393
|
+
// rides the same session-level `acl grant` read-set as the
|
|
394
|
+
// working tree. Granted on the mkdtemp DIR (not the file)
|
|
395
|
+
// so the (OI)(CI) ACE covers both the file open AND the
|
|
396
|
+
// parent-directory list that cmd's `type`/`FindFirstFile`
|
|
397
|
+
// does before opening. Mirrors the mac/linux
|
|
398
|
+
// `expandedAllowRead` push in wrapWithSandbox.
|
|
399
|
+
if (mitmCA) {
|
|
400
|
+
acc.grantRead.push(dirname(mitmCA.trustBundlePath));
|
|
401
|
+
}
|
|
402
|
+
// `u` was fetched once above for the provisioning gate; the
|
|
403
|
+
// same status carries the SID — don't re-spawn `srt-win user
|
|
404
|
+
// status` here.
|
|
405
|
+
if (!u.sid) {
|
|
406
|
+
throw new Error('sandbox user SID missing from `srt-win user status` ' +
|
|
407
|
+
'(provisioned but in an inconsistent state)');
|
|
408
|
+
}
|
|
409
|
+
const sb = u.sid;
|
|
410
|
+
// Record module-level state BEFORE the first acl call so the
|
|
411
|
+
// catch's best-effort revoke/restore can address whatever
|
|
412
|
+
// partially landed.
|
|
413
|
+
windowsFsSbUserSid = sb;
|
|
414
|
+
// Grant FIRST so the sandbox user has working-tree access by
|
|
415
|
+
// the time the deny stamp runs. The two are independent
|
|
416
|
+
// refcounted state-DB sets keyed on the same holder PID.
|
|
417
|
+
if (acc.grantRead.length > 0 || acc.grantWrite.length > 0) {
|
|
418
|
+
grantWindowsAcl({
|
|
419
|
+
sandboxUserSid: sb,
|
|
420
|
+
read: acc.grantRead,
|
|
421
|
+
write: acc.grantWrite,
|
|
422
|
+
srtWin,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
if (acc.denyRead.length > 0 || acc.denyWrite.length > 0) {
|
|
426
|
+
stampWindowsAcl({
|
|
427
|
+
sandboxUserSid: sb,
|
|
428
|
+
denyRead: acc.denyRead,
|
|
429
|
+
denyWrite: acc.denyWrite,
|
|
430
|
+
srtWin,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// Only record when something was actually applied — gates
|
|
434
|
+
// running revoke/restore at reset(). Recorded AFTER success —
|
|
435
|
+
// the catch below clears `config`, and a non-undefined
|
|
436
|
+
// stampedSet would leave reset()/updateConfig() seeing state
|
|
437
|
+
// that never landed.
|
|
438
|
+
const anyApplied = acc.grantRead.length > 0 ||
|
|
439
|
+
acc.grantWrite.length > 0 ||
|
|
440
|
+
acc.denyRead.length > 0 ||
|
|
441
|
+
acc.denyWrite.length > 0;
|
|
442
|
+
if (anyApplied) {
|
|
443
|
+
windowsFsStampedSet = acc;
|
|
444
|
+
logForDebugging(`[Sandbox Windows] fs applied: ` +
|
|
445
|
+
`${acc.grantWrite.length} grantWrite, ` +
|
|
446
|
+
`${acc.grantRead.length} grantRead, ` +
|
|
447
|
+
`${acc.denyRead.length} denyRead, ` +
|
|
448
|
+
`${acc.denyWrite.length} denyWrite`);
|
|
449
|
+
}
|
|
450
|
+
windowsFsRawInputs = rawWindowsFsInputs(runtimeConfig);
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
// Best-effort release of whatever WAS applied before the
|
|
454
|
+
// failure (exit-2 partial stamps/grants the resolvable
|
|
455
|
+
// inputs; harmless if nothing was — no holds for this PID).
|
|
456
|
+
if (windowsFsSbUserSid) {
|
|
457
|
+
revokeWindowsAcl({ sandboxUserSid: windowsFsSbUserSid, srtWin });
|
|
458
|
+
restoreWindowsAcl({ sandboxUserSid: windowsFsSbUserSid, srtWin });
|
|
459
|
+
}
|
|
460
|
+
windowsFsSbUserSid = undefined;
|
|
461
|
+
config = undefined;
|
|
462
|
+
throw e;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
288
465
|
// Initialize network infrastructure
|
|
289
466
|
initializationPromise = (async () => {
|
|
290
467
|
try {
|
|
@@ -302,27 +479,35 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
|
|
|
302
479
|
config.network.httpProxyPort !== undefined
|
|
303
480
|
? undefined
|
|
304
481
|
: randomBytes(16).toString('hex');
|
|
305
|
-
|
|
482
|
+
// The mux front-end serves both protocols on one port. Each side's
|
|
483
|
+
// reported port is the external override if configured, else the mux
|
|
484
|
+
// port — so the public config.network.{http,socks}ProxyPort contract
|
|
485
|
+
// is unchanged. The mux is skipped only when BOTH are external.
|
|
486
|
+
const needLocalProxy = config.network.httpProxyPort === undefined ||
|
|
487
|
+
config.network.socksProxyPort === undefined;
|
|
488
|
+
const muxPort = needLocalProxy
|
|
489
|
+
? await startMuxProxyServer(sandboxAskCallback, portRange)
|
|
490
|
+
: undefined;
|
|
491
|
+
const httpProxyPort = config.network.httpProxyPort ?? muxPort;
|
|
492
|
+
const socksProxyPort = config.network.socksProxyPort ?? muxPort;
|
|
493
|
+
// Leaves are minted lazily per-CONNECT (after this point), so setting
|
|
494
|
+
// the CDP URL now means every leaf carries it. See MitmCA.crlUrl.
|
|
495
|
+
// Windows-only: on Linux the child runs under bwrap --unshare-net and
|
|
496
|
+
// reaches the proxy via a socat bridge on a fixed netns port, so a
|
|
497
|
+
// host-namespace mux port would be unreachable — worse than no CDP,
|
|
498
|
+
// since a Schannel-analog client (Java, OpenSSL with CRL_CHECK) then
|
|
499
|
+
// hard-fails "CRL fetch error" instead of soft-passing "no CDP". macOS
|
|
500
|
+
// has no in-tree Schannel-analog client. Also gated on `muxPort`: an
|
|
501
|
+
// external `network.httpProxyPort` doesn't answer /srt.crl.
|
|
502
|
+
if (mitmCA && muxPort !== undefined && getPlatform() === 'windows') {
|
|
503
|
+
mitmCA.crlUrl = `http://127.0.0.1:${muxPort}${CRL_PATH}`;
|
|
504
|
+
}
|
|
306
505
|
if (config.network.httpProxyPort !== undefined) {
|
|
307
|
-
// Use external HTTP proxy (don't start a server)
|
|
308
|
-
httpProxyPort = config.network.httpProxyPort;
|
|
309
506
|
logForDebugging(`Using external HTTP proxy on port ${httpProxyPort}`);
|
|
310
507
|
}
|
|
311
|
-
else {
|
|
312
|
-
// Start local HTTP proxy
|
|
313
|
-
httpProxyPort = await startHttpProxyServer(sandboxAskCallback, portRange, new Set());
|
|
314
|
-
}
|
|
315
|
-
let socksProxyPort;
|
|
316
508
|
if (config.network.socksProxyPort !== undefined) {
|
|
317
|
-
// Use external SOCKS proxy (don't start a server)
|
|
318
|
-
socksProxyPort = config.network.socksProxyPort;
|
|
319
509
|
logForDebugging(`Using external SOCKS proxy on port ${socksProxyPort}`);
|
|
320
510
|
}
|
|
321
|
-
else {
|
|
322
|
-
// Start local SOCKS proxy. Skip the port the HTTP proxy
|
|
323
|
-
// already took.
|
|
324
|
-
socksProxyPort = await startSocksProxyServer(sandboxAskCallback, portRange, new Set([httpProxyPort]));
|
|
325
|
-
}
|
|
326
511
|
// Initialize platform-specific infrastructure
|
|
327
512
|
let linuxBridge;
|
|
328
513
|
if (getPlatform() === 'linux') {
|
|
@@ -359,16 +544,6 @@ function isSupportedPlatform() {
|
|
|
359
544
|
}
|
|
360
545
|
return platform === 'macos' || platform === 'windows';
|
|
361
546
|
}
|
|
362
|
-
/**
|
|
363
|
-
* Resolve the Windows group reference from config. Used by both the
|
|
364
|
-
* dependency check and `wrapWithSandbox` so they agree.
|
|
365
|
-
*/
|
|
366
|
-
function getWindowsGroupRef() {
|
|
367
|
-
return {
|
|
368
|
-
groupName: config?.windows?.groupName ?? DEFAULT_WINDOWS_GROUP_NAME,
|
|
369
|
-
groupSid: config?.windows?.groupSid,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
547
|
function isSandboxingEnabled() {
|
|
373
548
|
// Sandboxing is enabled if config has been set (via initialize())
|
|
374
549
|
return config !== undefined;
|
|
@@ -402,46 +577,110 @@ function checkDependencies(ripgrepConfig) {
|
|
|
402
577
|
warnings.push(...linuxDeps.warnings);
|
|
403
578
|
}
|
|
404
579
|
else if (platform === 'windows') {
|
|
405
|
-
|
|
580
|
+
let srtWin;
|
|
581
|
+
try {
|
|
582
|
+
srtWin = resolveSrtWin(config?.windows?.srtWin);
|
|
583
|
+
}
|
|
584
|
+
catch (e) {
|
|
585
|
+
errors.push(e.message);
|
|
586
|
+
return { errors, warnings };
|
|
587
|
+
}
|
|
588
|
+
const winDeps = checkWindowsDependencies({
|
|
589
|
+
sublayerGuid: config?.windows?.sublayerGuid ?? config?.windows?.wfpSublayerGuid,
|
|
590
|
+
srtWin,
|
|
591
|
+
});
|
|
406
592
|
errors.push(...winDeps.errors);
|
|
407
593
|
warnings.push(...winDeps.warnings);
|
|
408
594
|
}
|
|
409
595
|
return { errors, warnings };
|
|
410
596
|
}
|
|
411
597
|
/**
|
|
412
|
-
* Build the read-deny / env-unset
|
|
598
|
+
* Build the read-deny / env-unset / env-set maps implied by the
|
|
599
|
+
* `credentials` config.
|
|
413
600
|
*
|
|
414
601
|
* Only explicitly declared sources are restricted: `mode: 'deny'` file
|
|
415
|
-
* entries join the read-deny set
|
|
416
|
-
*
|
|
417
|
-
*
|
|
602
|
+
* entries join the read-deny set, `mode: 'deny'` env vars are unset, and
|
|
603
|
+
* `mode: 'mask'` env vars are set to a per-session sentinel registered in
|
|
604
|
+
* {@link sentinelRegistry}. A masked var with no value in the host
|
|
605
|
+
* environment is skipped — there is nothing to protect, and emitting an
|
|
606
|
+
* unset var would change tool behaviour (presence checks would pass where
|
|
607
|
+
* they didn't before).
|
|
418
608
|
*/
|
|
419
|
-
function getCredentialRestrictions(credentials) {
|
|
609
|
+
function getCredentialRestrictions(credentials, allowedDomains) {
|
|
420
610
|
if (!credentials) {
|
|
421
|
-
return {
|
|
611
|
+
return {
|
|
612
|
+
denyReadPaths: [],
|
|
613
|
+
unsetEnvVars: [],
|
|
614
|
+
setEnvVars: {},
|
|
615
|
+
maskedFileBinds: [],
|
|
616
|
+
maskedFileStoreDir: undefined,
|
|
617
|
+
};
|
|
422
618
|
}
|
|
619
|
+
const denyReadPaths = getCredentialDenyReadPaths(credentials);
|
|
620
|
+
const unsetEnvVars = [];
|
|
621
|
+
const setEnvVars = {};
|
|
622
|
+
for (const v of credentials.envVars ?? []) {
|
|
623
|
+
if (v.mode === 'deny') {
|
|
624
|
+
unsetEnvVars.push(v.name);
|
|
625
|
+
}
|
|
626
|
+
else if (v.mode === 'mask') {
|
|
627
|
+
const real = process.env[v.name];
|
|
628
|
+
if (real === undefined)
|
|
629
|
+
continue;
|
|
630
|
+
// Effective injectHosts: per-entry narrows; if unset, default to
|
|
631
|
+
// every reachable host (network.allowedDomains). injectHosts is an
|
|
632
|
+
// *optional narrowing*, not a required allowlist. Trade-off: a
|
|
633
|
+
// masked credential with no injectHosts is injectable at every host
|
|
634
|
+
// the sandbox can reach — narrow it explicitly when the credential
|
|
635
|
+
// should only go to a subset.
|
|
636
|
+
const injectHosts = v.injectHosts ?? allowedDomains ?? [];
|
|
637
|
+
setEnvVars[v.name] = sentinelRegistry.register(v.name, real, injectHosts);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Masked files: read the real bytes on the host, register a sentinel,
|
|
641
|
+
// write it to a fake file in the manager-owned temp dir. Missing/unreadable
|
|
642
|
+
// entries are skipped (same posture as an unset masked env var).
|
|
643
|
+
// degradeToDenyPaths carries paths whose extract pattern matched
|
|
644
|
+
// nothing with onExtractNoMatch: "deny" — merged into denyReadPaths
|
|
645
|
+
// below so both the read-deny config and the platform builders see them.
|
|
423
646
|
const files = credentials.files ?? [];
|
|
424
|
-
const
|
|
425
|
-
const unsetEnvVars = (credentials.envVars ?? [])
|
|
426
|
-
.filter(v => v.mode === 'deny')
|
|
427
|
-
.map(v => v.name);
|
|
647
|
+
const { binds: maskedFileBinds, degradeToDenyPaths } = buildMaskedFileBinds(files, allowedDomains ?? [], sentinelRegistry, maskedFileStore);
|
|
428
648
|
return {
|
|
429
|
-
denyReadPaths: [...new Set(denyReadPaths)],
|
|
649
|
+
denyReadPaths: [...new Set([...denyReadPaths, ...degradeToDenyPaths])],
|
|
430
650
|
unsetEnvVars: [...new Set(unsetEnvVars)],
|
|
651
|
+
setEnvVars,
|
|
652
|
+
maskedFileBinds,
|
|
653
|
+
maskedFileStoreDir: maskedFileStore.dirPath,
|
|
431
654
|
};
|
|
432
655
|
}
|
|
656
|
+
/**
|
|
657
|
+
* Pure (side-effect-free) chokepoint for credential file-deny
|
|
658
|
+
* paths — `credentials.files` entries with `mode: 'deny'`. Any
|
|
659
|
+
* code that needs the credential→denyRead contribution routes
|
|
660
|
+
* through here so a comparison predicate can read it without
|
|
661
|
+
* touching {@link sentinelRegistry}.
|
|
662
|
+
*/
|
|
663
|
+
function getCredentialDenyReadPaths(credentials) {
|
|
664
|
+
const files = credentials?.files ?? [];
|
|
665
|
+
return [...new Set(files.filter(f => f.mode === 'deny').map(f => f.path))];
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Union the explicit `filesystem.denyRead` with credential-derived
|
|
669
|
+
* deny paths. The single source of "what files does this config
|
|
670
|
+
* want read-denied" — all platforms route through here so a new
|
|
671
|
+
* credential kind that contributes deny paths reaches every
|
|
672
|
+
* backend.
|
|
673
|
+
*/
|
|
674
|
+
function unionDenyReadPaths(denyRead, credentialRestrictions) {
|
|
675
|
+
return [...new Set([...denyRead, ...credentialRestrictions.denyReadPaths])];
|
|
676
|
+
}
|
|
433
677
|
function getFsReadConfig() {
|
|
434
|
-
if (!config) {
|
|
678
|
+
if (!config || config.filesystem.disabled) {
|
|
435
679
|
return { denyOnly: [], allowWithinDeny: [] };
|
|
436
680
|
}
|
|
437
681
|
// Credential deny paths are unioned with the caller's denyRead — never
|
|
438
682
|
// replacing it — so explicit filesystem restrictions always survive.
|
|
439
|
-
const rawDenyRead =
|
|
440
|
-
...new Set([
|
|
441
|
-
...config.filesystem.denyRead,
|
|
442
|
-
...getCredentialRestrictions(config.credentials).denyReadPaths,
|
|
443
|
-
]),
|
|
444
|
-
];
|
|
683
|
+
const rawDenyRead = unionDenyReadPaths(config.filesystem.denyRead, getCredentialRestrictions(config.credentials, config.network.allowedDomains));
|
|
445
684
|
const denyPaths = [];
|
|
446
685
|
for (const p of rawDenyRead) {
|
|
447
686
|
const stripped = removeTrailingGlobSuffix(p);
|
|
@@ -477,6 +716,9 @@ function getFsWriteConfig() {
|
|
|
477
716
|
if (!config) {
|
|
478
717
|
return { allowOnly: getDefaultWritePaths(), denyWithinAllow: [] };
|
|
479
718
|
}
|
|
719
|
+
if (config.filesystem.disabled) {
|
|
720
|
+
return { allowOnly: ['/'], denyWithinAllow: [] };
|
|
721
|
+
}
|
|
480
722
|
// Filter out glob patterns on Linux/WSL for allowWrite (bubblewrap doesn't support globs)
|
|
481
723
|
const allowPaths = config.filesystem.allowWrite
|
|
482
724
|
.map(path => removeTrailingGlobSuffix(path))
|
|
@@ -504,6 +746,94 @@ function getFsWriteConfig() {
|
|
|
504
746
|
denyWithinAllow: denyPaths,
|
|
505
747
|
};
|
|
506
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* Build the Windows file-access set (deny stamps + sandbox-user
|
|
751
|
+
* grants) from `runtimeConfig`. Globs are expanded to concrete
|
|
752
|
+
* paths (point-in-time — a path appearing after this returns is NOT
|
|
753
|
+
* covered). Directory targets are accepted (the `(OI)(CI)` ACEs
|
|
754
|
+
* cover the subtree).
|
|
755
|
+
*
|
|
756
|
+
* The sandbox user has no inherent rights on real-user-owned files,
|
|
757
|
+
* so `allowWrite` (the working-tree roots) becomes a per-session
|
|
758
|
+
* `MODIFY_NO_FDC` ALLOW ACE for `<sb-SID>`, `allowRead` a
|
|
759
|
+
* `READ|EXECUTE` ALLOW ACE, and `denyRead`/`denyWrite` become an
|
|
760
|
+
* explicit DENY ACE for `<sb-SID>` on the target plus a
|
|
761
|
+
* `(OI)(CI) FILE_DELETE_CHILD` DENY on its parent.
|
|
762
|
+
*/
|
|
763
|
+
function computeWindowsFsAccessSet(c) {
|
|
764
|
+
const fs = c.filesystem;
|
|
765
|
+
// filesystem.disabled bypasses ALL filesystem rule generation —
|
|
766
|
+
// same as the macOS/Linux wrapWithSandbox path (readConfig /
|
|
767
|
+
// writeConfig left undefined). On Windows this means no ACL
|
|
768
|
+
// stamp/grant; credential FILE denies are dropped along with the
|
|
769
|
+
// rest (credential ENV: mode:'deny' is structural under the
|
|
770
|
+
// fresh srt-sandbox env; mode:'mask' sentinels are passed via
|
|
771
|
+
// the --env overlay).
|
|
772
|
+
if (fs?.disabled) {
|
|
773
|
+
return { grantRead: [], grantWrite: [], denyRead: [], denyWrite: [] };
|
|
774
|
+
}
|
|
775
|
+
const expand = expandWindowsFsPaths;
|
|
776
|
+
const denyRead = expand([
|
|
777
|
+
...new Set([
|
|
778
|
+
...(fs?.denyRead ?? []),
|
|
779
|
+
...getCredentialDenyReadPaths(c.credentials),
|
|
780
|
+
]),
|
|
781
|
+
]);
|
|
782
|
+
const denyWrite = expand(fs?.denyWrite ?? []);
|
|
783
|
+
return {
|
|
784
|
+
// `allowRead` also serves as `allowWithinDeny`: a file under a
|
|
785
|
+
// denied dir gets an explicit ALLOW ACE for the sandbox user,
|
|
786
|
+
// and explicit DENY on the parent doesn't override it because
|
|
787
|
+
// the recompose chokepoint orders deny-before-allow per-path.
|
|
788
|
+
grantRead: expand(fs?.allowRead ?? []),
|
|
789
|
+
grantWrite: expand(fs?.allowWrite ?? []),
|
|
790
|
+
denyRead,
|
|
791
|
+
denyWrite,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Snapshot the raw config fields that feed
|
|
796
|
+
* {@link computeWindowsFsAccessSet}. Used by updateConfig() to
|
|
797
|
+
* short-circuit the resolved-set diff (which re-runs glob
|
|
798
|
+
* expansion) when nothing relevant changed.
|
|
799
|
+
*/
|
|
800
|
+
function rawWindowsFsInputs(c) {
|
|
801
|
+
// Keyed exactly on what {@link computeWindowsFsAccessSet} reads.
|
|
802
|
+
// `network.allowedDomains` does NOT feed file-deny (only mask
|
|
803
|
+
// injectHosts), so a network-only updateConfig hits the cache.
|
|
804
|
+
return {
|
|
805
|
+
disabled: c.filesystem.disabled ?? false,
|
|
806
|
+
denyRead: [...c.filesystem.denyRead],
|
|
807
|
+
denyWrite: [...c.filesystem.denyWrite],
|
|
808
|
+
allowRead: [...(c.filesystem.allowRead ?? [])],
|
|
809
|
+
allowWrite: [...c.filesystem.allowWrite],
|
|
810
|
+
credFiles: getCredentialDenyReadPaths(c.credentials),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
function setEq(a, b) {
|
|
814
|
+
if (a.length !== b.length)
|
|
815
|
+
return false;
|
|
816
|
+
const s = new Set(a);
|
|
817
|
+
return b.every(x => s.has(x));
|
|
818
|
+
}
|
|
819
|
+
function sameRawWindowsFsInputs(a, b) {
|
|
820
|
+
return (a.disabled === b.disabled &&
|
|
821
|
+
setEq(a.denyRead, b.denyRead) &&
|
|
822
|
+
setEq(a.denyWrite, b.denyWrite) &&
|
|
823
|
+
setEq(a.allowRead, b.allowRead) &&
|
|
824
|
+
setEq(a.allowWrite, b.allowWrite) &&
|
|
825
|
+
setEq(a.credFiles, b.credFiles));
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* True when `newConfig`'s file-deny inputs match what was
|
|
829
|
+
* stamped at initialize(). Compares raw inputs only (cheap,
|
|
830
|
+
* order-insensitive); never re-expands globs — updateConfig is
|
|
831
|
+
* warn-only on Windows and the resolved set wouldn't be used.
|
|
832
|
+
*/
|
|
833
|
+
function sameWindowsStampSet(newConfig) {
|
|
834
|
+
return (windowsFsRawInputs !== undefined &&
|
|
835
|
+
sameRawWindowsFsInputs(windowsFsRawInputs, rawWindowsFsInputs(newConfig)));
|
|
836
|
+
}
|
|
507
837
|
function getNetworkRestrictionConfig() {
|
|
508
838
|
if (!config) {
|
|
509
839
|
return {};
|
|
@@ -593,6 +923,22 @@ async function waitForNetworkInitialization() {
|
|
|
593
923
|
}
|
|
594
924
|
async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
595
925
|
const platform = getPlatform();
|
|
926
|
+
// filesystem.disabled bypasses ALL filesystem rule generation. Both
|
|
927
|
+
// platform wrappers treat readConfig/writeConfig === undefined as "no
|
|
928
|
+
// filesystem restrictions" (seatbelt emits `(allow file-write*)`; bwrap
|
|
929
|
+
// skips the `--ro-bind / /` root and all path binds).
|
|
930
|
+
//
|
|
931
|
+
// Precedence: when a caller passes a per-call filesystem override at all,
|
|
932
|
+
// its `disabled` (defaulting to false) wins outright. A global
|
|
933
|
+
// disabled=true must not silently discard a per-call tightening that
|
|
934
|
+
// omits the new key.
|
|
935
|
+
const fsDisabled = customConfig?.filesystem !== undefined
|
|
936
|
+
? (customConfig.filesystem.disabled ?? false)
|
|
937
|
+
: (config?.filesystem.disabled ?? false);
|
|
938
|
+
// Credential env handling is independent of filesystem policy: unsetEnvVars /
|
|
939
|
+
// setEnvVars must be applied even when fsDisabled (the credential file
|
|
940
|
+
// deny-reads are dropped, but env scrubbing still happens).
|
|
941
|
+
const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
|
|
596
942
|
// Get configs - use custom if provided, otherwise fall back to main config
|
|
597
943
|
// If neither exists, defaults to empty arrays (most restrictive)
|
|
598
944
|
// Always include default system write paths (like /dev/null, /tmp/claude)
|
|
@@ -600,63 +946,62 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
600
946
|
// Strip trailing /** and filter remaining globs on Linux (bwrap needs
|
|
601
947
|
// real paths, not globs; macOS subpath matching is also recursive so
|
|
602
948
|
// stripping is harmless there).
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
...new Set([
|
|
623
|
-
...(customConfig?.filesystem?.denyRead ??
|
|
624
|
-
config?.filesystem.denyRead ??
|
|
949
|
+
let writeConfig;
|
|
950
|
+
let readConfig;
|
|
951
|
+
if (!fsDisabled) {
|
|
952
|
+
const stripWriteGlobs = (paths) => paths
|
|
953
|
+
.map(p => removeTrailingGlobSuffix(p))
|
|
954
|
+
.filter(p => {
|
|
955
|
+
if (getPlatform() === 'linux' && containsGlobChars(p)) {
|
|
956
|
+
logForDebugging(`[Sandbox] Skipping glob write pattern on Linux: ${p}`);
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
return true;
|
|
960
|
+
});
|
|
961
|
+
const userAllowWrite = stripWriteGlobs(customConfig?.filesystem?.allowWrite ??
|
|
962
|
+
config?.filesystem.allowWrite ??
|
|
963
|
+
[]);
|
|
964
|
+
writeConfig = {
|
|
965
|
+
allowOnly: [...getDefaultWritePaths(), ...userAllowWrite],
|
|
966
|
+
denyWithinAllow: stripWriteGlobs(customConfig?.filesystem?.denyWrite ??
|
|
967
|
+
config?.filesystem.denyWrite ??
|
|
625
968
|
[]),
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
969
|
+
};
|
|
970
|
+
// Credential deny paths are unioned with the caller's denyRead — never
|
|
971
|
+
// replacing it — so explicit filesystem restrictions always survive.
|
|
972
|
+
const rawDenyRead = unionDenyReadPaths(customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions);
|
|
973
|
+
const expandedDenyRead = [];
|
|
974
|
+
for (const p of rawDenyRead) {
|
|
975
|
+
const stripped = removeTrailingGlobSuffix(p);
|
|
976
|
+
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
|
977
|
+
expandedDenyRead.push(...expandGlobPattern(p));
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
expandedDenyRead.push(stripped);
|
|
981
|
+
}
|
|
637
982
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
983
|
+
const rawAllowRead = customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [];
|
|
984
|
+
const expandedAllowRead = [];
|
|
985
|
+
for (const p of rawAllowRead) {
|
|
986
|
+
const stripped = removeTrailingGlobSuffix(p);
|
|
987
|
+
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
|
988
|
+
expandedAllowRead.push(...expandGlobPattern(p));
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
expandedAllowRead.push(stripped);
|
|
992
|
+
}
|
|
645
993
|
}
|
|
646
|
-
|
|
647
|
-
|
|
994
|
+
// The TLS-termination CA cert and the trust bundle the env vars point at
|
|
995
|
+
// (NODE_EXTRA_CA_CERTS etc.) must be readable by the child, even if their
|
|
996
|
+
// paths fall under a user-configured denyRead.
|
|
997
|
+
if (mitmCA) {
|
|
998
|
+
expandedAllowRead.push(mitmCA.certPath, mitmCA.trustBundlePath);
|
|
648
999
|
}
|
|
1000
|
+
readConfig = {
|
|
1001
|
+
denyOnly: expandedDenyRead,
|
|
1002
|
+
allowWithinDeny: expandedAllowRead,
|
|
1003
|
+
};
|
|
649
1004
|
}
|
|
650
|
-
// The TLS-termination CA cert must be readable by the child so the trust
|
|
651
|
-
// env vars (NODE_EXTRA_CA_CERTS etc.) resolve, even if its path falls
|
|
652
|
-
// under a user-configured denyRead.
|
|
653
|
-
if (mitmCA) {
|
|
654
|
-
expandedAllowRead.push(mitmCA.certPath);
|
|
655
|
-
}
|
|
656
|
-
const readConfig = {
|
|
657
|
-
denyOnly: expandedDenyRead,
|
|
658
|
-
allowWithinDeny: expandedAllowRead,
|
|
659
|
-
};
|
|
660
1005
|
// Check if network config is specified - this determines if we need network restrictions
|
|
661
1006
|
// Network restriction is needed when:
|
|
662
1007
|
// 1. customConfig has network.allowedDomains defined (even if empty array = block all)
|
|
@@ -690,10 +1035,12 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
690
1035
|
httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined,
|
|
691
1036
|
socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined,
|
|
692
1037
|
proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
|
|
693
|
-
caCertPath: mitmCA?.
|
|
1038
|
+
caCertPath: mitmCA?.trustBundlePath,
|
|
694
1039
|
readConfig,
|
|
695
1040
|
writeConfig,
|
|
696
1041
|
unsetEnvVars: credentialRestrictions.unsetEnvVars,
|
|
1042
|
+
setEnvVars: credentialRestrictions.setEnvVars,
|
|
1043
|
+
maskedFileBinds: credentialRestrictions.maskedFileBinds,
|
|
697
1044
|
allowUnixSockets: getAllowUnixSockets(),
|
|
698
1045
|
allowAllUnixSockets: getAllowAllUnixSockets(),
|
|
699
1046
|
allowLocalBinding: getAllowLocalBinding(),
|
|
@@ -724,10 +1071,13 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
724
1071
|
? managerContext?.socksProxyPort
|
|
725
1072
|
: undefined,
|
|
726
1073
|
proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
|
|
727
|
-
caCertPath: mitmCA?.
|
|
1074
|
+
caCertPath: mitmCA?.trustBundlePath,
|
|
728
1075
|
readConfig,
|
|
729
1076
|
writeConfig,
|
|
730
1077
|
unsetEnvVars: credentialRestrictions.unsetEnvVars,
|
|
1078
|
+
setEnvVars: credentialRestrictions.setEnvVars,
|
|
1079
|
+
maskedFileBinds: credentialRestrictions.maskedFileBinds,
|
|
1080
|
+
maskedFileStoreDir: credentialRestrictions.maskedFileStoreDir,
|
|
731
1081
|
enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(),
|
|
732
1082
|
allowAllUnixSockets: getAllowAllUnixSockets(),
|
|
733
1083
|
binShell,
|
|
@@ -737,6 +1087,7 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
737
1087
|
seccompConfig: getSeccompConfig(),
|
|
738
1088
|
bwrapPath: config?.bwrapPath,
|
|
739
1089
|
socatPath: config?.socatPath,
|
|
1090
|
+
observeSocketPath: linuxMonitor?.observeSocketPath,
|
|
740
1091
|
abortSignal,
|
|
741
1092
|
});
|
|
742
1093
|
case 'windows':
|
|
@@ -758,14 +1109,22 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
758
1109
|
* `spawn(argv[0], argv.slice(1), {shell: false, env})`.
|
|
759
1110
|
*
|
|
760
1111
|
* On Windows this is the ONLY supported wrap method (see
|
|
761
|
-
* {@link wrapWithSandbox}); `env`
|
|
762
|
-
* sandboxed child
|
|
763
|
-
*
|
|
1112
|
+
* {@link wrapWithSandbox}); `env` is the broker process's spawn env
|
|
1113
|
+
* — the sandboxed child gets a fresh `srt-sandbox` profile env with
|
|
1114
|
+
* only the `--env` overlay baked into `argv` (see
|
|
1115
|
+
* {@link wrapCommandWithSandboxWindows}). On
|
|
764
1116
|
* macOS/Linux `argv` is `[binShell, '-c', <wrapWithSandbox result>]`
|
|
765
1117
|
* (proxy env is baked into that command) and `env` is the unchanged
|
|
766
1118
|
* `process.env`, so callers can spawn uniformly across platforms.
|
|
1119
|
+
*
|
|
1120
|
+
* @param cwd the working directory the caller will spawn the result
|
|
1121
|
+
* with. On Windows the child's cwd is whatever the caller passes
|
|
1122
|
+
* as the spawn `{cwd:}` option (there is no `--cwd` flag), and
|
|
1123
|
+
* the `safe.directory` git-config injection derives from this — so
|
|
1124
|
+
* pass the same value here as to `spawn({cwd})`. Defaults to
|
|
1125
|
+
* `process.cwd()`. Currently unused on macOS/Linux.
|
|
767
1126
|
*/
|
|
768
|
-
async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal) {
|
|
1127
|
+
async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal, cwd) {
|
|
769
1128
|
const platform = getPlatform();
|
|
770
1129
|
if (platform === 'windows') {
|
|
771
1130
|
const hasNetworkConfig = customConfig?.network?.allowedDomains !== undefined ||
|
|
@@ -773,13 +1132,83 @@ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal)
|
|
|
773
1132
|
if (hasNetworkConfig) {
|
|
774
1133
|
await waitForNetworkInitialization();
|
|
775
1134
|
}
|
|
1135
|
+
const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
|
|
1136
|
+
// Per-exec FILE denies (customConfig only — the session-level
|
|
1137
|
+
// config's denies were already stamped at initialize()).
|
|
1138
|
+
// Paths go through `expandWindowsFsPaths` — the SAME
|
|
1139
|
+
// chokepoint the session-level set uses (point-in-time glob
|
|
1140
|
+
// expand, normalize, missing→drop) — so a per-exec entry
|
|
1141
|
+
// resolves identically to its session-level equivalent.
|
|
1142
|
+
// macOS/Linux per-exec already reuses session-level expansion;
|
|
1143
|
+
// Windows now matches.
|
|
1144
|
+
//
|
|
1145
|
+
// The dedup against `windowsFsStampedSet` is an OPTIMIZATION,
|
|
1146
|
+
// not a correctness gate: re-stamping a session-held path
|
|
1147
|
+
// under the exec's distinct holder is refcount-safe but wastes
|
|
1148
|
+
// a SetSecurityInfo round-trip.
|
|
1149
|
+
//
|
|
1150
|
+
// filesystem.disabled bypasses ALL filesystem rule generation
|
|
1151
|
+
// — including credential-derived file denies — same ordering
|
|
1152
|
+
// as session-level `computeWindowsFsAccessSet` (credential
|
|
1153
|
+
// ENV: mode:'deny' is structural under the fresh srt-sandbox
|
|
1154
|
+
// env; mode:'mask' sentinels are passed via the --env
|
|
1155
|
+
// overlay).
|
|
1156
|
+
// Per-exec allowRead/allowWrite throw — `srt-win exec` only
|
|
1157
|
+
// exposes `--deny-*`; per-exec grants are not implemented.
|
|
1158
|
+
const fsCfg = customConfig?.filesystem;
|
|
1159
|
+
let perExecDenyRead = [];
|
|
1160
|
+
let perExecDenyWrite = [];
|
|
1161
|
+
if (!fsCfg?.disabled) {
|
|
1162
|
+
if (fsCfg?.allowRead?.length || fsCfg?.allowWrite?.length) {
|
|
1163
|
+
throw new Error(`Per-exec filesystem.allowRead/allowWrite is not supported ` +
|
|
1164
|
+
`on Windows — \`srt-win exec\` only exposes per-exec ` +
|
|
1165
|
+
`denies. Set them at the session level (initialize()).`);
|
|
1166
|
+
}
|
|
1167
|
+
const rawRead = [
|
|
1168
|
+
...(fsCfg?.denyRead ?? []),
|
|
1169
|
+
...getCredentialDenyReadPaths(customConfig?.credentials),
|
|
1170
|
+
];
|
|
1171
|
+
const rawWrite = fsCfg?.denyWrite ?? [];
|
|
1172
|
+
// Skip on the dominant path (no per-exec fs or
|
|
1173
|
+
// credential-file deny).
|
|
1174
|
+
if (rawRead.length > 0 || rawWrite.length > 0) {
|
|
1175
|
+
const sessRead = new Set(windowsFsStampedSet?.denyRead ?? []);
|
|
1176
|
+
const sessWrite = new Set(windowsFsStampedSet?.denyWrite ?? []);
|
|
1177
|
+
const expand = expandWindowsFsPaths;
|
|
1178
|
+
perExecDenyRead = expand(rawRead).filter(p => !sessRead.has(p));
|
|
1179
|
+
perExecDenyWrite = expand(rawWrite).filter(p => !sessRead.has(p) && !sessWrite.has(p));
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
// Per-exec deny rides on argv (`acl stamp` reads stdin, but
|
|
1183
|
+
// exec's stdin belongs to the child). The CreateProcessW
|
|
1184
|
+
// length check lives in `wrapCommandWithSandboxWindows`
|
|
1185
|
+
// where the full argv (incl. shell + user command) is known.
|
|
1186
|
+
//
|
|
1187
|
+
// The `denyReadPaths` half of the SESSION-level credentials
|
|
1188
|
+
// is already unioned into the stamp set at initialize() time
|
|
1189
|
+
// via `computeWindowsFsAccessSet`.
|
|
776
1190
|
return wrapCommandWithSandboxWindows({
|
|
777
1191
|
command,
|
|
778
|
-
group: getWindowsGroupRef(),
|
|
779
1192
|
httpProxyPort: hasNetworkConfig ? getProxyPort() : undefined,
|
|
780
1193
|
socksProxyPort: hasNetworkConfig ? getSocksProxyPort() : undefined,
|
|
781
1194
|
proxyAuthToken: hasNetworkConfig ? proxyAuthToken : undefined,
|
|
782
|
-
|
|
1195
|
+
// mode:'deny' env vars are structurally absent (fresh
|
|
1196
|
+
// srt-sandbox profile env). mode:'mask' sentinels are
|
|
1197
|
+
// passed via the --env overlay so the sandboxed child sees
|
|
1198
|
+
// the sentinel value, same as macOS/Linux.
|
|
1199
|
+
setEnvVars: credentialRestrictions.setEnvVars,
|
|
1200
|
+
denyRead: perExecDenyRead,
|
|
1201
|
+
denyWrite: perExecDenyWrite,
|
|
1202
|
+
// safe.directory: cwd + the resolved session-level write
|
|
1203
|
+
// grants — exactly the working-tree roots the sandbox user
|
|
1204
|
+
// has MODIFY on and where git will see real-user-owned files.
|
|
1205
|
+
cwd,
|
|
1206
|
+
allowWrite: windowsFsStampedSet?.grantWrite,
|
|
1207
|
+
caCertPath: mitmCA?.trustBundlePath,
|
|
1208
|
+
binShell: parseWindowsBinShell(binShell),
|
|
1209
|
+
srtWin: customConfig?.windows?.srtWin
|
|
1210
|
+
? resolveSrtWin(customConfig.windows.srtWin)
|
|
1211
|
+
: (srtWinSpawn ?? resolveSrtWin(config?.windows?.srtWin)),
|
|
783
1212
|
});
|
|
784
1213
|
}
|
|
785
1214
|
// macOS/Linux: delegate to the existing string wrapper, then put
|
|
@@ -799,7 +1228,7 @@ function getConfig() {
|
|
|
799
1228
|
* Update the sandbox configuration in place.
|
|
800
1229
|
*
|
|
801
1230
|
* **Network/allowlist changes are a live swap**: the running
|
|
802
|
-
*
|
|
1231
|
+
* mux proxy reads `config.network.allowedDomains` /
|
|
803
1232
|
* `deniedDomains` per-request (via `filterNetworkRequest`), so
|
|
804
1233
|
* reassigning `config` here takes effect on the next connection
|
|
805
1234
|
* with no proxy rebind and no port change — on every platform,
|
|
@@ -808,12 +1237,21 @@ function getConfig() {
|
|
|
808
1237
|
*
|
|
809
1238
|
* Filesystem changes (denyRead/denyWrite) are NOT applied live:
|
|
810
1239
|
* macOS bakes them into the seatbelt profile at wrap time, and
|
|
811
|
-
* Windows
|
|
812
|
-
*
|
|
1240
|
+
* Linux/Windows bake them into the bwrap argv / DENY-ACE set at
|
|
1241
|
+
* wrap time. Call reset() + initialize() to apply a new
|
|
1242
|
+
* filesystem config.
|
|
813
1243
|
*
|
|
814
1244
|
* @param newConfig - The new configuration to use
|
|
815
1245
|
*/
|
|
816
1246
|
function updateConfig(newConfig) {
|
|
1247
|
+
if (getPlatform() === 'windows' &&
|
|
1248
|
+
config &&
|
|
1249
|
+
!sameWindowsStampSet(newConfig)) {
|
|
1250
|
+
logForDebugging(`[Sandbox Windows] updateConfig: the resolved file-access set ` +
|
|
1251
|
+
`(filesystem.* ∪ credentials.files) changed but the ACL ` +
|
|
1252
|
+
`stamp/grant is session-wide — call reset() then initialize() ` +
|
|
1253
|
+
`to apply. The previously-applied set stays in effect.`, { level: 'warn' });
|
|
1254
|
+
}
|
|
817
1255
|
// Deep clone the config to avoid mutations. structuredClone cannot clone
|
|
818
1256
|
// functions, so pull filterRequest out, clone the rest, and put it back —
|
|
819
1257
|
// a function reference is immutable in the sense that matters here.
|
|
@@ -950,6 +1388,38 @@ function forceCloseHttpServer(server) {
|
|
|
950
1388
|
});
|
|
951
1389
|
}
|
|
952
1390
|
async function reset() {
|
|
1391
|
+
// Windows: release this session's sandbox-user ACEs. Best-effort
|
|
1392
|
+
// — log anomalies rather than throw, so teardown always
|
|
1393
|
+
// completes. Leftover ACEs are recoverable later via
|
|
1394
|
+
// `srt-win acl recover` (which sweeps by trustee SID).
|
|
1395
|
+
if (windowsFsStampedSet && windowsFsSbUserSid) {
|
|
1396
|
+
const sb = windowsFsSbUserSid;
|
|
1397
|
+
// Captured at initialize() — the SAME binary the grants/stamps
|
|
1398
|
+
// were applied with, immune to `config` mutation between.
|
|
1399
|
+
const srtWin = srtWinSpawn;
|
|
1400
|
+
// 'restored'/'alreadyOriginal' are the pre- same-user-removal
|
|
1401
|
+
// srt-win's success vocabulary; 'revoked'/'stillHeld' are the
|
|
1402
|
+
// post-. Either is non-anomalous.
|
|
1403
|
+
const ok = new Set(['revoked', 'stillHeld', 'restored', 'alreadyOriginal']);
|
|
1404
|
+
const log = (kind, e) => {
|
|
1405
|
+
if (!ok.has(e.status)) {
|
|
1406
|
+
logForDebugging(`[Sandbox Windows] ${kind}: '${e.path}' ${e.status} — ` +
|
|
1407
|
+
`ACE may be left in place; resolve and run ` +
|
|
1408
|
+
`\`srt-win acl recover\` to clear`, { level: 'warn' });
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
for (const e of revokeWindowsAcl({ sandboxUserSid: sb, srtWin }) ?? []) {
|
|
1412
|
+
log('grant revoke', e);
|
|
1413
|
+
}
|
|
1414
|
+
for (const e of restoreWindowsAcl({ sandboxUserSid: sb, srtWin }) ?? []) {
|
|
1415
|
+
log('deny restore', e);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
windowsFsStampedSet = undefined;
|
|
1419
|
+
windowsFsSbUserSid = undefined;
|
|
1420
|
+
windowsFsRawInputs = undefined;
|
|
1421
|
+
srtWinSpawn = undefined;
|
|
1422
|
+
// windowsWfpVerified is NOT cleared — per-process, not per-session.
|
|
953
1423
|
// Clean up any leftover bwrap mount points. Force past the
|
|
954
1424
|
// active-sandbox counter — reset() means the session is over.
|
|
955
1425
|
cleanupBwrapMountPoints({ force: true });
|
|
@@ -958,6 +1428,10 @@ async function reset() {
|
|
|
958
1428
|
logMonitorShutdown();
|
|
959
1429
|
logMonitorShutdown = undefined;
|
|
960
1430
|
}
|
|
1431
|
+
if (linuxMonitor) {
|
|
1432
|
+
linuxMonitor.stop();
|
|
1433
|
+
linuxMonitor = undefined;
|
|
1434
|
+
}
|
|
961
1435
|
if (managerContext?.linuxBridge) {
|
|
962
1436
|
const { httpSocketPath, socksSocketPath, httpBridgeProcess, socksBridgeProcess, } = managerContext.linuxBridge;
|
|
963
1437
|
// Kill both bridges and wait for them to exit
|
|
@@ -994,6 +1468,13 @@ async function reset() {
|
|
|
994
1468
|
if (mitmCA) {
|
|
995
1469
|
closePromises.push(disposeMitmCA(mitmCA));
|
|
996
1470
|
}
|
|
1471
|
+
if (muxProxyServer) {
|
|
1472
|
+
closePromises.push(muxProxyServer.close().catch((error) => {
|
|
1473
|
+
logForDebugging(`Error closing mux proxy server: ${error.message}`, {
|
|
1474
|
+
level: 'error',
|
|
1475
|
+
});
|
|
1476
|
+
}));
|
|
1477
|
+
}
|
|
997
1478
|
if (httpProxyServer) {
|
|
998
1479
|
closePromises.push(forceCloseHttpServer(httpProxyServer));
|
|
999
1480
|
}
|
|
@@ -1008,6 +1489,7 @@ async function reset() {
|
|
|
1008
1489
|
// Wait for all servers to close
|
|
1009
1490
|
await Promise.all(closePromises);
|
|
1010
1491
|
// Clear references
|
|
1492
|
+
muxProxyServer = undefined;
|
|
1011
1493
|
httpProxyServer = undefined;
|
|
1012
1494
|
proxyAuthToken = undefined;
|
|
1013
1495
|
socksProxyServer = undefined;
|
|
@@ -1015,6 +1497,8 @@ async function reset() {
|
|
|
1015
1497
|
initializationPromise = undefined;
|
|
1016
1498
|
parentProxy = undefined;
|
|
1017
1499
|
mitmCA = undefined;
|
|
1500
|
+
sentinelRegistry.clear();
|
|
1501
|
+
maskedFileStore.dispose();
|
|
1018
1502
|
}
|
|
1019
1503
|
function getSandboxViolationStore() {
|
|
1020
1504
|
return sandboxViolationStore;
|
|
@@ -1045,7 +1529,7 @@ function annotateStderrWithSandboxFailures(command, stderr) {
|
|
|
1045
1529
|
function getLinuxGlobPatternWarnings() {
|
|
1046
1530
|
// Only warn on Linux/WSL (bubblewrap doesn't support globs)
|
|
1047
1531
|
// macOS supports glob patterns via regex conversion
|
|
1048
|
-
if (getPlatform() !== 'linux' || !config) {
|
|
1532
|
+
if (getPlatform() !== 'linux' || !config || config.filesystem.disabled) {
|
|
1049
1533
|
return [];
|
|
1050
1534
|
}
|
|
1051
1535
|
const globPatterns = [];
|
|
@@ -1096,6 +1580,8 @@ export const SandboxManager = {
|
|
|
1096
1580
|
cleanupAfterCommand,
|
|
1097
1581
|
reset,
|
|
1098
1582
|
getMitmCA: () => mitmCA,
|
|
1583
|
+
getSentinelRegistry: () => sentinelRegistry,
|
|
1584
|
+
getMaskedFileStore: () => maskedFileStore,
|
|
1099
1585
|
getSandboxViolationStore,
|
|
1100
1586
|
annotateStderrWithSandboxFailures,
|
|
1101
1587
|
getLinuxGlobPatternWarnings,
|