@sysid/sandbox-runtime-improved 0.0.56-sysid.1 → 0.0.61-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 +43 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sandbox/credential-mask-files.d.ts +72 -0
- package/dist/sandbox/credential-mask-files.d.ts.map +1 -0
- package/dist/sandbox/credential-mask-files.js +146 -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 +32 -1
- package/dist/sandbox/http-proxy.d.ts.map +1 -1
- package/dist/sandbox/http-proxy.js +10 -2
- package/dist/sandbox/http-proxy.js.map +1 -1
- package/dist/sandbox/linux-sandbox-utils.d.ts +15 -0
- package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/linux-sandbox-utils.js +106 -47
- package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
- 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 +69 -14
- package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
- package/dist/sandbox/mitm-ca.d.ts +18 -2
- package/dist/sandbox/mitm-ca.d.ts.map +1 -1
- package/dist/sandbox/mitm-ca.js +49 -9
- package/dist/sandbox/mitm-ca.js.map +1 -1
- package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
- package/dist/sandbox/mitm-leaf.js +24 -6
- 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 +254 -25
- package/dist/sandbox/sandbox-config.d.ts.map +1 -1
- package/dist/sandbox/sandbox-config.js +200 -13
- package/dist/sandbox/sandbox-config.js.map +1 -1
- package/dist/sandbox/sandbox-manager.d.ts +4 -0
- package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
- package/dist/sandbox/sandbox-manager.js +601 -194
- 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 +42 -6
- package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/sandbox-utils.js +115 -27
- 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 +347 -11
- package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/windows-sandbox-utils.js +437 -45
- package/dist/sandbox/windows-sandbox-utils.js.map +1 -1
- package/package.json +7 -4
- package/vendor/seccomp/build.ts +8 -30
- 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
|
@@ -3,7 +3,10 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { logForDebugging } from '../utils/debug.js';
|
|
6
|
-
import { generateProxyEnvVars } from './sandbox-utils.js';
|
|
6
|
+
import { containsGlobCharsWin, expandGlobPattern, generateProxyEnvVars, normalizePathForSandbox, } from './sandbox-utils.js';
|
|
7
|
+
// Re-export so existing tests (glob-expand.test.ts) and any
|
|
8
|
+
// out-of-tree caller keep their import path.
|
|
9
|
+
export { containsGlobCharsWin, stripExtendedPathPrefix, } from './sandbox-utils.js';
|
|
7
10
|
/**
|
|
8
11
|
* Windows sandbox backend.
|
|
9
12
|
*
|
|
@@ -19,7 +22,12 @@ import { generateProxyEnvVars } from './sandbox-utils.js';
|
|
|
19
22
|
* token-membership check; WFP via providerData-tag enumeration under
|
|
20
23
|
* the configured sublayer). There is no marker file.
|
|
21
24
|
*
|
|
22
|
-
* Filesystem
|
|
25
|
+
* Filesystem deny (`denyRead`/`denyWrite`) is enforced via
|
|
26
|
+
* `srt-win acl stamp` at session start: a broker-only DACL is
|
|
27
|
+
* applied to each listed file plus a `Modify`-minus-`FILE_DELETE_CHILD`
|
|
28
|
+
* allow-list on its immediate parent directory, with restore state
|
|
29
|
+
* sealed by an inert hash-ACE marker so the on-disk SD is
|
|
30
|
+
* self-authenticating. See {@link stampWindowsAcl}.
|
|
23
31
|
*/
|
|
24
32
|
// ────────────────────────────────────────────────────────────────────
|
|
25
33
|
// Types
|
|
@@ -28,6 +36,50 @@ export const DEFAULT_WINDOWS_GROUP_NAME = 'sandbox-runtime-net';
|
|
|
28
36
|
export const DEFAULT_WINDOWS_PROXY_PORT_RANGE = [
|
|
29
37
|
60080, 60089,
|
|
30
38
|
];
|
|
39
|
+
/**
|
|
40
|
+
* Adapter from the cross-platform `binShell?: string` surface
|
|
41
|
+
* ({@link SandboxManager.wrapWithSandboxArgv}) to the Windows
|
|
42
|
+
* discriminated union. Throws on any value outside the recognised
|
|
43
|
+
* set — there is no silent fallback to cmd.exe.
|
|
44
|
+
*
|
|
45
|
+
* Uses `path.win32` explicitly so the function (and its unit test)
|
|
46
|
+
* is platform-independent.
|
|
47
|
+
*/
|
|
48
|
+
export function parseWindowsBinShell(raw) {
|
|
49
|
+
if (raw === undefined)
|
|
50
|
+
return { kind: 'cmd' };
|
|
51
|
+
// bash/sh: path semantics — match on basename, keep the caller's
|
|
52
|
+
// absolute path verbatim.
|
|
53
|
+
const base = path.win32.basename(raw).toLowerCase();
|
|
54
|
+
if (base === 'bash' ||
|
|
55
|
+
base === 'bash.exe' ||
|
|
56
|
+
base === 'sh' ||
|
|
57
|
+
base === 'sh.exe') {
|
|
58
|
+
if (!path.win32.isAbsolute(raw)) {
|
|
59
|
+
throw new Error(`binShell bash path must be absolute (got ${JSON.stringify(raw)}); ` +
|
|
60
|
+
`pass the resolved Git Bash install path`);
|
|
61
|
+
}
|
|
62
|
+
return { kind: 'bash', path: raw };
|
|
63
|
+
}
|
|
64
|
+
// cmd/powershell/pwsh: token semantics — match on the FULL string,
|
|
65
|
+
// not basename, so an absolute path to pwsh.exe (whose path we'd
|
|
66
|
+
// otherwise discard) falls through to the explicit throw rather
|
|
67
|
+
// than silently degrading to a PATH lookup.
|
|
68
|
+
switch (raw.toLowerCase()) {
|
|
69
|
+
case 'pwsh':
|
|
70
|
+
case 'pwsh.exe':
|
|
71
|
+
return { kind: 'pwsh' };
|
|
72
|
+
case 'powershell':
|
|
73
|
+
case 'powershell.exe':
|
|
74
|
+
return { kind: 'powershell' };
|
|
75
|
+
case 'cmd':
|
|
76
|
+
case 'cmd.exe':
|
|
77
|
+
return { kind: 'cmd' };
|
|
78
|
+
default:
|
|
79
|
+
throw new Error(`unrecognised binShell ${JSON.stringify(raw)}: expected ` +
|
|
80
|
+
`'cmd' | 'powershell' | 'pwsh' or an absolute path to bash.exe/sh.exe`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
31
83
|
// ────────────────────────────────────────────────────────────────────
|
|
32
84
|
// Binary resolution
|
|
33
85
|
// ────────────────────────────────────────────────────────────────────
|
|
@@ -36,12 +88,20 @@ function repoRoot() {
|
|
|
36
88
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
37
89
|
return path.resolve(here, '..', '..');
|
|
38
90
|
}
|
|
91
|
+
const nodeArchToDir = { x64: 'x64', arm64: 'arm64' };
|
|
39
92
|
/**
|
|
40
93
|
* Locate `srt-win.exe`. Resolution order:
|
|
41
94
|
* 1. `SRT_WIN_PATH` env var (CI sets this to the freshly-built binary).
|
|
42
|
-
* 2. `<
|
|
43
|
-
*
|
|
44
|
-
*
|
|
95
|
+
* 2. `<root>/vendor/srt-win/{arch}/srt-win.exe` (prebuilt — published npm
|
|
96
|
+
* package, or after `npm run build:srt-win` locally).
|
|
97
|
+
* 3. `<root>/vendor/srt-win-src/target/release/srt-win.exe` (local
|
|
98
|
+
* `cargo build --release` fallback for development).
|
|
99
|
+
* 4. `<root>/vendor/srt-win/target/release/srt-win.exe` (transitional:
|
|
100
|
+
* stale local build from before the srt-win-src rename).
|
|
101
|
+
*
|
|
102
|
+
* `<root>` is {@link repoRoot} — `__dirname/../..`, which resolves to the
|
|
103
|
+
* repo root from `src/sandbox/` and `dist/sandbox/` alike, and to the
|
|
104
|
+
* package root when installed under `node_modules`.
|
|
45
105
|
*
|
|
46
106
|
* Resolution via the optional `@anthropic-ai/sandbox-runtime-win32-*`
|
|
47
107
|
* platform packages is added separately.
|
|
@@ -54,16 +114,20 @@ export function getSrtWinPath() {
|
|
|
54
114
|
return envPath;
|
|
55
115
|
}
|
|
56
116
|
const root = repoRoot();
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
117
|
+
const arch = nodeArchToDir[process.arch];
|
|
118
|
+
const candidates = [];
|
|
119
|
+
if (arch) {
|
|
120
|
+
candidates.push(path.join(root, 'vendor', 'srt-win', arch, 'srt-win.exe'));
|
|
121
|
+
}
|
|
122
|
+
candidates.push(path.join(root, 'vendor', 'srt-win-src', 'target', 'release', 'srt-win.exe'),
|
|
123
|
+
// transitional: stale local build from before the srt-win-src rename
|
|
124
|
+
path.join(root, 'vendor', 'srt-win', 'target', 'release', 'srt-win.exe'));
|
|
61
125
|
for (const c of candidates) {
|
|
62
126
|
if (fs.existsSync(c))
|
|
63
127
|
return c;
|
|
64
128
|
}
|
|
65
129
|
throw new Error(`srt-win.exe not found. Set SRT_WIN_PATH or build with ` +
|
|
66
|
-
`\`cargo build --release --manifest-path vendor/srt-win/Cargo.toml\`. ` +
|
|
130
|
+
`\`cargo build --release --manifest-path vendor/srt-win-src/Cargo.toml\`. ` +
|
|
67
131
|
`Looked in: ${[envPath, ...candidates].filter(Boolean).join(', ')}`);
|
|
68
132
|
}
|
|
69
133
|
// ────────────────────────────────────────────────────────────────────
|
|
@@ -74,9 +138,13 @@ function groupRefArgs(ref) {
|
|
|
74
138
|
return ['--group-sid', ref.groupSid];
|
|
75
139
|
return ['--name', ref.groupName ?? DEFAULT_WINDOWS_GROUP_NAME];
|
|
76
140
|
}
|
|
77
|
-
function runSrtWin(args) {
|
|
141
|
+
function runSrtWin(args, stdin, timeoutMs = 15000) {
|
|
78
142
|
const exe = getSrtWinPath();
|
|
79
|
-
const r = spawnSync(exe, args, {
|
|
143
|
+
const r = spawnSync(exe, args, {
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
timeout: timeoutMs,
|
|
146
|
+
...(stdin !== undefined && { input: stdin }),
|
|
147
|
+
});
|
|
80
148
|
if (r.error) {
|
|
81
149
|
throw new Error(`srt-win ${args[0]}: spawn failed: ${r.error.message}`);
|
|
82
150
|
}
|
|
@@ -86,20 +154,29 @@ function runSrtWin(args) {
|
|
|
86
154
|
stderr: (r.stderr ?? '').trim(),
|
|
87
155
|
};
|
|
88
156
|
}
|
|
89
|
-
function runSrtWinJson(args) {
|
|
90
|
-
const r = runSrtWin(args);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
157
|
+
function runSrtWinJson(args, opts) {
|
|
158
|
+
const r = runSrtWin(args, undefined, opts?.timeoutMs);
|
|
159
|
+
// Parse stdout BEFORE the exit-code check so `allowNonZero`
|
|
160
|
+
// can hand back the JSON on exit≠0. JSON.parse never returns
|
|
161
|
+
// `undefined`, so `json !== undefined` ⇔ parse succeeded.
|
|
162
|
+
let json;
|
|
163
|
+
let parseErr;
|
|
96
164
|
try {
|
|
97
|
-
|
|
165
|
+
json = JSON.parse(r.stdout);
|
|
98
166
|
}
|
|
99
167
|
catch (e) {
|
|
100
|
-
|
|
101
|
-
|
|
168
|
+
parseErr = e.message;
|
|
169
|
+
}
|
|
170
|
+
if (opts?.allowNonZero && json !== undefined) {
|
|
171
|
+
return { ok: r.status === 0, json, stderr: r.stderr };
|
|
102
172
|
}
|
|
173
|
+
if (r.status !== 0) {
|
|
174
|
+
throw new Error(`srt-win ${args.join(' ')} exited ${r.status}: ${r.stderr || r.stdout}`);
|
|
175
|
+
}
|
|
176
|
+
if (json !== undefined)
|
|
177
|
+
return json;
|
|
178
|
+
throw new Error(`srt-win ${args.join(' ')}: unparseable JSON output ` +
|
|
179
|
+
`${JSON.stringify(r.stdout)}: ${parseErr}`);
|
|
103
180
|
}
|
|
104
181
|
// ────────────────────────────────────────────────────────────────────
|
|
105
182
|
// Status / install API
|
|
@@ -129,8 +206,83 @@ export function getWindowsWfpStatus(opts = {}) {
|
|
|
129
206
|
state: raw.state,
|
|
130
207
|
filters: raw.filters,
|
|
131
208
|
...(raw.port_range && { portRange: raw.port_range }),
|
|
209
|
+
userFilters: raw.user_filters ?? 0,
|
|
210
|
+
...(raw.user_sid && { userSid: raw.user_sid }),
|
|
132
211
|
};
|
|
133
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Query the sandbox user account's provisioning state. Each field
|
|
215
|
+
* is independently observed so a half-provisioned install (e.g.
|
|
216
|
+
* user exists but credential file missing) is distinguishable.
|
|
217
|
+
* Does not require elevation.
|
|
218
|
+
*/
|
|
219
|
+
export function getWindowsSandboxUserStatus() {
|
|
220
|
+
const raw = runSrtWinJson(['user', 'status']);
|
|
221
|
+
return {
|
|
222
|
+
provisioned: raw.user.exists,
|
|
223
|
+
...(raw.user.sid && { sid: raw.user.sid }),
|
|
224
|
+
groupExists: raw.user.group_exists,
|
|
225
|
+
...(raw.user.group_sid && { groupSid: raw.user.group_sid }),
|
|
226
|
+
inBuiltinUsers: raw.user.in_builtin_users,
|
|
227
|
+
inSandboxGroup: raw.user.in_sandbox_group,
|
|
228
|
+
hiddenFromLogon: raw.user.hidden_from_logon,
|
|
229
|
+
credPresent: raw.cred_present,
|
|
230
|
+
...(typeof raw.marker_version === 'number' && {
|
|
231
|
+
markerVersion: raw.marker_version,
|
|
232
|
+
}),
|
|
233
|
+
...(raw.ca_cert_thumb && { caCertThumb: raw.ca_cert_thumb }),
|
|
234
|
+
...(raw.ca_cert_pem && { caCertPem: raw.ca_cert_pem }),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Read back the persistent MITM CA the sandbox was installed with
|
|
239
|
+
* (via `srt-win user trust-ca` / {@link windowsTrustCa}).
|
|
240
|
+
* Returns `null` when no CA was installed. The PEM is what `srt-win
|
|
241
|
+
* user status` reconstructs from the DER stored in `state.db`.
|
|
242
|
+
*
|
|
243
|
+
* On Windows with `windows.asSandboxUser`, `tlsTerminate` requires
|
|
244
|
+
* this CA to be present in the sandbox user's `CurrentUser\Root`
|
|
245
|
+
* (schannel-level trust is an install-time concern, not
|
|
246
|
+
* per-session); the host calls this from `initialize()` to fail
|
|
247
|
+
* early with an actionable message when it isn't.
|
|
248
|
+
*
|
|
249
|
+
* @param status pass an already-fetched
|
|
250
|
+
* {@link getWindowsSandboxUserStatus} result to avoid a second
|
|
251
|
+
* `srt-win user status` spawn.
|
|
252
|
+
*/
|
|
253
|
+
export function getWindowsSandboxCaCert(status) {
|
|
254
|
+
const u = status ?? getWindowsSandboxUserStatus();
|
|
255
|
+
if (!u.caCertThumb || !u.caCertPem)
|
|
256
|
+
return null;
|
|
257
|
+
return { pem: u.caCertPem, thumb: u.caCertThumb };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Install (or replace) the MITM CA in the **sandbox user's**
|
|
261
|
+
* `CurrentUser\Root` and record it in `state.db` (so
|
|
262
|
+
* {@link getWindowsSandboxCaCert} surfaces its thumbprint + PEM).
|
|
263
|
+
* Thin wrapper around `srt-win user trust-ca <path>`. Does NOT
|
|
264
|
+
* require elevation. Persistent until {@link uninstallWindowsSandbox}
|
|
265
|
+
* deletes the sandbox user's profile.
|
|
266
|
+
*
|
|
267
|
+
* The CA has a separate lifecycle from {@link installWindowsSandbox}
|
|
268
|
+
* — install provisions the account/filters and never touches the CA;
|
|
269
|
+
* call this AFTER install when `tlsTerminate` will be used with
|
|
270
|
+
* `windows.asSandboxUser`.
|
|
271
|
+
*
|
|
272
|
+
* @throws when the sandbox user is not provisioned, the file is not a
|
|
273
|
+
* parseable X.509 certificate, or the registry write into the
|
|
274
|
+
* sandbox user's hive fails.
|
|
275
|
+
*/
|
|
276
|
+
export function windowsTrustCa(caCertPath) {
|
|
277
|
+
// 60s: first call may create the sandbox user's profile
|
|
278
|
+
// (LOGON_WITH_PROFILE) via the one-shot CreateProcessWithLogonW.
|
|
279
|
+
const r = runSrtWin(['user', 'trust-ca', caCertPath], undefined, 60000);
|
|
280
|
+
logForDebugging(`[Sandbox Windows] user trust-ca exit=${r.status}: ${r.stderr || r.stdout}`);
|
|
281
|
+
if (r.status !== 0) {
|
|
282
|
+
throw new Error(`srt-win user trust-ca '${caCertPath}' failed (exit ` +
|
|
283
|
+
`${r.status}): ${r.stderr || r.stdout}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
134
286
|
/**
|
|
135
287
|
* One-shot install: creates the discriminator group, adds the
|
|
136
288
|
* current user (or `userSid`), and installs the machine-wide WFP
|
|
@@ -164,7 +316,7 @@ export function installWindowsSandbox(opts = {}) {
|
|
|
164
316
|
}
|
|
165
317
|
if (opts.force)
|
|
166
318
|
args.push('--force');
|
|
167
|
-
const r = runSrtWin(args);
|
|
319
|
+
const r = runSrtWin(args, undefined, 60000);
|
|
168
320
|
logForDebugging(`[Sandbox Windows] install exit=${r.status}: ${r.stderr || r.stdout}`);
|
|
169
321
|
// srt-win install exit-code contract:
|
|
170
322
|
// 0 ok
|
|
@@ -172,6 +324,7 @@ export function installWindowsSandbox(opts = {}) {
|
|
|
172
324
|
// 11 group create failed
|
|
173
325
|
// 12 WFP install failed
|
|
174
326
|
// 13 already installed with different config (use --force)
|
|
327
|
+
// 14 sandbox-user provisioning failed
|
|
175
328
|
// 1 other error (stderr has detail)
|
|
176
329
|
const out = r.stderr || r.stdout;
|
|
177
330
|
switch (r.status) {
|
|
@@ -181,12 +334,15 @@ export function installWindowsSandbox(opts = {}) {
|
|
|
181
334
|
return {
|
|
182
335
|
group: getWindowsGroupStatus(opts),
|
|
183
336
|
wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid }),
|
|
337
|
+
user: getWindowsSandboxUserStatus(),
|
|
184
338
|
cancelled: true,
|
|
185
339
|
};
|
|
186
340
|
case 11:
|
|
187
341
|
throw new Error(`srt-win install: group create failed: ${out}`);
|
|
188
342
|
case 12:
|
|
189
343
|
throw new Error(`srt-win install: WFP filter install failed: ${out}`);
|
|
344
|
+
case 14:
|
|
345
|
+
throw new Error(`srt-win install: sandbox user provisioning failed: ${out}`);
|
|
190
346
|
case 13:
|
|
191
347
|
throw new Error(`srt-win install: filters already exist under this sublayer with ` +
|
|
192
348
|
`different configuration (group SID or port range). ` +
|
|
@@ -198,6 +354,7 @@ export function installWindowsSandbox(opts = {}) {
|
|
|
198
354
|
return {
|
|
199
355
|
group: getWindowsGroupStatus(opts),
|
|
200
356
|
wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid }),
|
|
357
|
+
user: getWindowsSandboxUserStatus(),
|
|
201
358
|
};
|
|
202
359
|
}
|
|
203
360
|
/**
|
|
@@ -209,12 +366,19 @@ export function installWindowsSandbox(opts = {}) {
|
|
|
209
366
|
* re-do the logout dance on the next install. Call
|
|
210
367
|
* {@link deleteWindowsGroup} explicitly if you want full teardown.
|
|
211
368
|
*
|
|
369
|
+
* **Does** remove the `srt-sandbox` account, its credential file,
|
|
370
|
+
* and the setup marker, unless `keepUser` is set — the credential
|
|
371
|
+
* is useless without the account and vice versa, so they're
|
|
372
|
+
* treated as one unit.
|
|
373
|
+
*
|
|
212
374
|
* @returns `{cancelled: true}` if the user dismissed UAC.
|
|
213
375
|
*/
|
|
214
376
|
export function uninstallWindowsSandbox(opts = {}) {
|
|
215
377
|
const args = ['uninstall'];
|
|
216
378
|
if (opts.sublayerGuid)
|
|
217
379
|
args.push('--sublayer-guid', opts.sublayerGuid);
|
|
380
|
+
if (opts.keepUser)
|
|
381
|
+
args.push('--keep-user');
|
|
218
382
|
const r = runSrtWin(args);
|
|
219
383
|
logForDebugging(`[Sandbox Windows] uninstall exit=${r.status}: ${r.stderr || r.stdout}`);
|
|
220
384
|
if (r.status === 10)
|
|
@@ -280,6 +444,136 @@ export function createWindowsWfp(ref) {
|
|
|
280
444
|
}
|
|
281
445
|
logForDebugging(`[Sandbox Windows] wfp install: ${r.stderr || r.stdout}`);
|
|
282
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Expand the `denyRead`/`denyWrite` input set to a flat list of
|
|
449
|
+
* existing FILE paths for `srt-win acl stamp`.
|
|
450
|
+
*
|
|
451
|
+
* Every input goes through {@link normalizePathForSandbox} (the
|
|
452
|
+
* single Windows-aware chokepoint: `\\?\`/UNC-strip, drive-letter
|
|
453
|
+
* case-fold, ~-expand, realpath). Globs (`*`/`?` only — `[`/`]`
|
|
454
|
+
* are legal Win32 filename chars) expand via the shared walker
|
|
455
|
+
* with case-insensitive matching (point-in-time: a file appearing
|
|
456
|
+
* after this returns is NOT covered). Each candidate is checked
|
|
457
|
+
* with one `statSync({throwIfNoEntry:false})`: missing → drop
|
|
458
|
+
* (the protection model covers files present at session start);
|
|
459
|
+
* directory → reject (the file stamp applies a per-file DACL plus
|
|
460
|
+
* a per-parent-directory allow-list; stamping a directory itself
|
|
461
|
+
* would touch every child); file → keep.
|
|
462
|
+
*/
|
|
463
|
+
export function expandWindowsFsDenyPaths(patterns) {
|
|
464
|
+
const out = new Set();
|
|
465
|
+
for (const raw of patterns) {
|
|
466
|
+
const norm = normalizePathForSandbox(raw);
|
|
467
|
+
const candidates = containsGlobCharsWin(norm)
|
|
468
|
+
? expandGlobPattern(norm, { caseInsensitive: true })
|
|
469
|
+
: [norm];
|
|
470
|
+
for (const c of candidates) {
|
|
471
|
+
const st = fs.statSync(c, { throwIfNoEntry: false });
|
|
472
|
+
if (!st)
|
|
473
|
+
continue;
|
|
474
|
+
if (st.isDirectory()) {
|
|
475
|
+
throw new Error(`Windows fs deny requires explicit file paths; ` +
|
|
476
|
+
`${JSON.stringify(raw)} resolved to directory ` +
|
|
477
|
+
`${JSON.stringify(c)}. Directory targets are not supported.`);
|
|
478
|
+
}
|
|
479
|
+
out.add(c);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return [...out];
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Apply the file-deny stamp set for one host session. Idempotent
|
|
486
|
+
* via `srt-win`'s disk-first `ensure_stamped` chokepoint — calling
|
|
487
|
+
* this again with overlapping paths re-verifies the on-disk DACL
|
|
488
|
+
* against the hash-ACE marker rather than trusting state-DB rows.
|
|
489
|
+
*
|
|
490
|
+
* Inputs are passed verbatim to `srt-win` (which canonicalizes,
|
|
491
|
+
* rejects directories and globs, and stamps each file plus its
|
|
492
|
+
* immediate parent directory). Callers that accept globs should
|
|
493
|
+
* pre-expand via {@link expandWindowsFsDenyPaths}.
|
|
494
|
+
*
|
|
495
|
+
* @throws on exit ≠ 0 — including exit 2 (one or more inputs
|
|
496
|
+
* skipped). srt-win stamps the resolvable inputs before exiting
|
|
497
|
+
* 2, so on throw the caller should call {@link restoreWindowsAcl}
|
|
498
|
+
* to release whatever WAS stamped (fail-closed at session start
|
|
499
|
+
* means tearing down a partial setup).
|
|
500
|
+
*/
|
|
501
|
+
export function stampWindowsAcl(opts) {
|
|
502
|
+
const holder = opts.holderPid ?? process.pid;
|
|
503
|
+
const stdin = JSON.stringify({
|
|
504
|
+
denyRead: opts.denyRead,
|
|
505
|
+
denyWrite: opts.denyWrite,
|
|
506
|
+
});
|
|
507
|
+
const r = runSrtWin(['acl', 'stamp', ...groupRefArgs(opts.group), '--holder-pid', `${holder}`], stdin, 60000);
|
|
508
|
+
logForDebugging(`[Sandbox Windows] acl stamp exit=${r.status}: ${r.stderr || r.stdout}`);
|
|
509
|
+
if (r.status !== 0) {
|
|
510
|
+
// exit 2 = partial (some inputs skipped); exit 1 = at least
|
|
511
|
+
// one path could not be stamped. Either is a setup failure.
|
|
512
|
+
throw new Error(`srt-win acl stamp exited ${r.status} ` +
|
|
513
|
+
(r.status === 2 ? '(partial — some inputs skipped)' : '(failed)') +
|
|
514
|
+
`: ${r.stderr || r.stdout}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Release this holder's file-deny stamps and return per-path /
|
|
519
|
+
* per-parent outcomes. Best-effort: a non-`restored` entry means
|
|
520
|
+
* the file's stamp was LEFT in place (fail-closed) — see
|
|
521
|
+
* {@link WindowsAclPathOutcome} for the cases. Does not throw on
|
|
522
|
+
* anomalies; the caller decides whether to surface them.
|
|
523
|
+
*
|
|
524
|
+
* Returns `undefined` when `srt-win acl restore` itself failed
|
|
525
|
+
* (no JSON to parse) — the caller should log and move on rather
|
|
526
|
+
* than block teardown.
|
|
527
|
+
*/
|
|
528
|
+
export function restoreWindowsAcl(opts) {
|
|
529
|
+
const holder = opts.holderPid ?? process.pid;
|
|
530
|
+
const args = [
|
|
531
|
+
'acl',
|
|
532
|
+
'restore',
|
|
533
|
+
...groupRefArgs(opts.group),
|
|
534
|
+
'--holder-pid',
|
|
535
|
+
`${holder}`,
|
|
536
|
+
'--json',
|
|
537
|
+
];
|
|
538
|
+
// Don't let a teardown helper throw — the caller's reset() must
|
|
539
|
+
// complete. runSrtWinJson parses stdout before checking the
|
|
540
|
+
// exit code, so a non-zero exit with the per-path JSON intact
|
|
541
|
+
// (`acl restore` prints outcomes THEN errors when any path
|
|
542
|
+
// stayed stamped) still surfaces every entry to reset()'s loop
|
|
543
|
+
// instead of collapsing to `undefined`. Only spawn-fail /
|
|
544
|
+
// unparseable output throws → log and return undefined.
|
|
545
|
+
try {
|
|
546
|
+
const r = runSrtWinJson(args, {
|
|
547
|
+
timeoutMs: 60000,
|
|
548
|
+
allowNonZero: true,
|
|
549
|
+
});
|
|
550
|
+
if (!r.ok) {
|
|
551
|
+
logForDebugging(`[Sandbox Windows] acl restore exited non-zero (per-path ` +
|
|
552
|
+
`outcomes preserved): ${r.stderr}`, { level: 'error' });
|
|
553
|
+
}
|
|
554
|
+
return r.json;
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
logForDebugging(`[Sandbox Windows] acl restore: ${e.message}`, {
|
|
558
|
+
level: 'error',
|
|
559
|
+
});
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Per-path outcomes that mean the file's DACL was returned to its
|
|
565
|
+
* pre-stamp state (or was already there). Anything else left the
|
|
566
|
+
* stamp in place and should be surfaced to the user.
|
|
567
|
+
*/
|
|
568
|
+
export const WINDOWS_ACL_PATH_OK = new Set([
|
|
569
|
+
'restored',
|
|
570
|
+
]);
|
|
571
|
+
/**
|
|
572
|
+
* Per-parent-directory outcomes that are expected during normal
|
|
573
|
+
* teardown. `stillHeld` is normal when another active session
|
|
574
|
+
* still references a file under this directory.
|
|
575
|
+
*/
|
|
576
|
+
export const WINDOWS_ACL_PARENT_OK = new Set(['restored', 'alreadyOriginal', 'stillHeld']);
|
|
283
577
|
// ────────────────────────────────────────────────────────────────────
|
|
284
578
|
// Wrap
|
|
285
579
|
// ────────────────────────────────────────────────────────────────────
|
|
@@ -290,7 +584,7 @@ export function createWindowsWfp(ref) {
|
|
|
290
584
|
* Caller MUST spawn the result with `{shell: false}` — that is the
|
|
291
585
|
* security boundary that keeps untrusted bytes off the host's shell
|
|
292
586
|
* (the inner `cmd.exe /c` runs INSIDE the sandbox; see
|
|
293
|
-
* `vendor/srt-win/src/launch.rs` `build_cmdline` for the passthrough
|
|
587
|
+
* `vendor/srt-win-src/src/launch.rs` `build_cmdline` for the passthrough
|
|
294
588
|
* rationale) — AND with the returned `env`.
|
|
295
589
|
*
|
|
296
590
|
* Proxy configuration is single-sourced by {@link generateProxyEnvVars}
|
|
@@ -302,30 +596,128 @@ export function createWindowsWfp(ref) {
|
|
|
302
596
|
*/
|
|
303
597
|
export function wrapCommandWithSandboxWindows(p) {
|
|
304
598
|
const exe = getSrtWinPath();
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const generated = envListToObject(generateProxyEnvVars(p.httpProxyPort, p.socksProxyPort, undefined, p.proxyAuthToken));
|
|
599
|
+
// Generated proxy + CA-trust env. Single-sourced here so the
|
|
600
|
+
// same object feeds (a) the spawn env merge below and (b) the
|
|
601
|
+
// explicit `--env` overlay for the two-hop launch.
|
|
602
|
+
//
|
|
603
|
+
// Under `asSandboxUser` the CA-bundle path is OMITTED: it points
|
|
604
|
+
// into the broker's `%TEMP%\srt-sandbox-…`, which the
|
|
605
|
+
// `srt-sandbox` user cannot read — OpenSSL-backed clients (msys2
|
|
606
|
+
// curl, `git -c http.sslBackend=openssl`, node, python) would
|
|
607
|
+
// fail to open it (curl exit 77 etc.). Schannel-level trust comes
|
|
608
|
+
// from the registry write `srt-win user trust-ca` did at install
|
|
609
|
+
// time; the env-var bundle layer for the two-hop path lands with
|
|
610
|
+
// the working-tree/profile-grant work. See WindowsSandboxParams.
|
|
611
|
+
if (p.asSandboxUser && p.caCertPath !== undefined) {
|
|
612
|
+
logForDebugging(`[Sandbox Windows] caCertPath '${p.caCertPath}' not forwarded ` +
|
|
613
|
+
`under asSandboxUser (broker %TEMP% is unreadable by ` +
|
|
614
|
+
`srt-sandbox); schannel trust via 'srt-win user trust-ca' ` +
|
|
615
|
+
`is the only CA-trust path for the two-hop launch`);
|
|
616
|
+
}
|
|
617
|
+
const generated = envListToObject(generateProxyEnvVars(p.httpProxyPort, p.socksProxyPort, p.asSandboxUser ? undefined : p.caCertPath, p.proxyAuthToken));
|
|
325
618
|
// TMPDIR is a POSIX path meant for the macOS/Linux FS sandbox — it
|
|
326
619
|
// serves no purpose on Windows and breaks msys2 tools (mktemp etc.).
|
|
327
620
|
delete generated.TMPDIR;
|
|
328
|
-
const
|
|
621
|
+
const argv = [exe, 'exec', ...groupRefArgs(p.group)];
|
|
622
|
+
// Format-validated at the config boundary
|
|
623
|
+
// (`WindowsConfigSchema.wfpSublayerGuid: z.string().uuid()`),
|
|
624
|
+
// and again by clap's GUID parser at the binary boundary; the
|
|
625
|
+
// outer spawn is `shell:false`, so the value is an argv element,
|
|
626
|
+
// never shell-interpolated.
|
|
627
|
+
if (p.sublayerGuid)
|
|
628
|
+
argv.push('--sublayer-guid', p.sublayerGuid);
|
|
629
|
+
if (p.holderPid !== undefined) {
|
|
630
|
+
argv.push('--holder-pid', `${p.holderPid}`);
|
|
631
|
+
}
|
|
632
|
+
for (const f of p.denyRead ?? [])
|
|
633
|
+
argv.push('--deny-read', f);
|
|
634
|
+
for (const f of p.denyWrite ?? [])
|
|
635
|
+
argv.push('--deny-write', f);
|
|
636
|
+
if (p.asSandboxUser) {
|
|
637
|
+
argv.push('--as-sandbox-user');
|
|
638
|
+
// The two-hop runner starts with the SANDBOX user's profile env
|
|
639
|
+
// (USERPROFILE/TEMP isolated) and overlays exactly what we pass
|
|
640
|
+
// as `--env`. The broker does NOT enumerate its own env — the
|
|
641
|
+
// overlay is built here from the same single source as the
|
|
642
|
+
// same-user path's spawn env.
|
|
643
|
+
const overlay = {
|
|
644
|
+
PATH: process.env.PATH,
|
|
645
|
+
PATHEXT: process.env.PATHEXT,
|
|
646
|
+
...generated,
|
|
647
|
+
};
|
|
648
|
+
for (const [k, v] of Object.entries(overlay)) {
|
|
649
|
+
if (v !== undefined)
|
|
650
|
+
argv.push('--env', `${k}=${v}`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
argv.push('--');
|
|
654
|
+
const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
|
|
655
|
+
const sh = p.binShell ?? { kind: 'cmd' };
|
|
656
|
+
switch (sh.kind) {
|
|
657
|
+
case 'bash':
|
|
658
|
+
// Git Bash: invoke the caller-supplied path directly with
|
|
659
|
+
// `-c <command>`. `command` is a fully-assembled bash command
|
|
660
|
+
// string with its own internal quoting; srt-win's `build_cmdline`
|
|
661
|
+
// takes the generic non-cmd branch and MSVCRT-quotes it as a
|
|
662
|
+
// SINGLE argv element, so bash receives it intact as argv[2].
|
|
663
|
+
// TODO: MSYS2 derives POSIX /tmp from Windows TEMP/TMP itself;
|
|
664
|
+
// revisit whether any extra TEMP/TMP normalisation is needed for
|
|
665
|
+
// the bash inner shell under the restricted token.
|
|
666
|
+
argv.push(sh.path, '-c', p.command);
|
|
667
|
+
break;
|
|
668
|
+
case 'pwsh':
|
|
669
|
+
argv.push('pwsh.exe', '-NoProfile', '-Command', p.command);
|
|
670
|
+
break;
|
|
671
|
+
case 'powershell':
|
|
672
|
+
argv.push(path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), '-NoProfile', '-Command', p.command);
|
|
673
|
+
break;
|
|
674
|
+
case 'cmd':
|
|
675
|
+
// cmd /d (no AutoRun) /s (strip first+last quote of post-/c by
|
|
676
|
+
// position) /c (run-then-exit). The `command` string lands as a
|
|
677
|
+
// single argv element; srt-win's build_cmdline wraps it in one
|
|
678
|
+
// outer "…" pair for /s to consume. See launch.rs.
|
|
679
|
+
argv.push(path.join(systemRoot, 'System32', 'cmd.exe'), '/d', '/s', '/c', p.command);
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
// CreateProcessW's lpCommandLine is capped at 32 767 WCHARs.
|
|
683
|
+
// Node's `shell:false` spawn builds it by MSVCRT-quoting each
|
|
684
|
+
// argv element and joining with spaces; the worst-case quote
|
|
685
|
+
// overhead per element is +2 (wrapping `"`) plus backslash
|
|
686
|
+
// doubling before embedded quotes (rare in file paths). A
|
|
687
|
+
// path-count proxy can't see this — 60 long paths overflow
|
|
688
|
+
// while 200 short ones fit — so estimate the actual command
|
|
689
|
+
// line and refuse with the same guidance the old count check
|
|
690
|
+
// gave. ~30 000 leaves headroom for the quote overhead the
|
|
691
|
+
// estimate doesn't model.
|
|
692
|
+
const cmdlineEstimate = argv.reduce((n, a) => n + a.length + 3, 0);
|
|
693
|
+
if (cmdlineEstimate > 30000) {
|
|
694
|
+
const nDeny = (p.denyRead?.length ?? 0) + (p.denyWrite?.length ?? 0);
|
|
695
|
+
throw new Error(`Windows sandbox argv is ~${cmdlineEstimate} chars ` +
|
|
696
|
+
`(CreateProcessW limit is 32 767). ${nDeny} per-exec ` +
|
|
697
|
+
`file-deny path(s) ride on this argv — move broad globs to ` +
|
|
698
|
+
`the session-level filesystem.denyRead/denyWrite (passed to ` +
|
|
699
|
+
`SandboxManager.initialize(), stdin-passed to \`acl stamp\`) ` +
|
|
700
|
+
`instead, or shorten the command.`);
|
|
701
|
+
}
|
|
702
|
+
// Drop/overwrite denied credential env vars from the inherited
|
|
703
|
+
// environment FIRST. The proxy assignments below must come LAST
|
|
704
|
+
// so SRT's own proxy plumbing vars survive even if a caller lists
|
|
705
|
+
// one of them as a denied credential — same precedence as the
|
|
706
|
+
// macOS/Linux `env -u … VAR=… sandbox-exec` order.
|
|
707
|
+
//
|
|
708
|
+
// Windows env is case-insensitive but Node preserves the OS
|
|
709
|
+
// casing on enumeration, so a `delete baseEnv['SECRET']` would
|
|
710
|
+
// miss a `Secret` key. Match by uppercased name instead.
|
|
711
|
+
const baseEnv = { ...process.env };
|
|
712
|
+
const unsetUpper = new Set((p.unsetEnvVars ?? []).map(k => k.toUpperCase()));
|
|
713
|
+
for (const k of Object.keys(baseEnv)) {
|
|
714
|
+
if (unsetUpper.has(k.toUpperCase()))
|
|
715
|
+
delete baseEnv[k];
|
|
716
|
+
}
|
|
717
|
+
Object.assign(baseEnv, p.setEnvVars ?? {});
|
|
718
|
+
// Generated proxy vars override any inherited (or just-masked)
|
|
719
|
+
// ones so the child always routes through this sandbox's proxies.
|
|
720
|
+
const env = { ...baseEnv, ...generated };
|
|
329
721
|
return { argv, env };
|
|
330
722
|
}
|
|
331
723
|
/**
|