dsh-wsl-desktop 0.2.0

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.
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Linux-side confinement for commands that run inside a WSL distribution.
3
+ *
4
+ * The host's `ctx.sandbox` provider cannot confine a `wsl.exe` process: its
5
+ * children execute on the Linux kernel side. Confinement therefore happens
6
+ * *inside* the distribution, by wrapping the inner shell command in a mount
7
+ * namespace whose root is read-only and whose only writable paths are the
8
+ * session workspace and a private `/tmp`.
9
+ *
10
+ * The runner needs root, because the WSL kernel refuses bind mounts inside a
11
+ * user namespace (`unshare -Ur --mount` succeeds but `mount --bind` fails with
12
+ * "wrong fs type"). `sudo -n unshare …` is used when the distribution's user
13
+ * has passwordless sudo; otherwise the confined modes fail loud rather than
14
+ * running unconfined.
15
+ *
16
+ * Entering the namespace through `sudo` changes the effective user, so the
17
+ * command is dropped back to the original uid/gid with `setpriv`. Without that
18
+ * step every file the command creates would be owned by root.
19
+ * @module dsh-wsl-desktop/wsl/confinement
20
+ */
21
+
22
+ import { shellQuote } from './paths.js'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { dirname, join } from 'node:path'
25
+
26
+ /** Shipped helper script, installed by the operator (see README). */
27
+ export const HELPER_SOURCE = join(dirname(fileURLToPath(import.meta.url)), 'dsh-wsl-confine.sh')
28
+
29
+ /** The direct runner: sudo-wrapped unshare with the fence built in-process. */
30
+ export const RUNNER_SUDO_UNSHARE = 'sudo-unshare'
31
+
32
+ /** The hardened runner: a root-owned helper that always fences before exec. */
33
+ export const RUNNER_HELPER = 'helper'
34
+
35
+ /** Where the operator installs the confinement helper (see README). */
36
+ export const HELPER_PATH = '/usr/local/sbin/dsh-wsl-confine'
37
+
38
+ /** stderr text the kernel produces for a write blocked by the read-only root. */
39
+ export const DENIAL_SIGNATURES = ['Read-only file system', 'read-only file system']
40
+
41
+ /** Cached probe results, keyed by distribution and user. */
42
+ const identityCache = new Map()
43
+ const runnerCache = new Map()
44
+ const noNewPrivsCache = new Map()
45
+
46
+ /** Cache key for one distribution/user pair. */
47
+ function keyFor(distro, username) {
48
+ return `${distro}\u0000${username ?? ''}`
49
+ }
50
+
51
+ /**
52
+ * Resolve the uid/gid a command must run as after the namespace is entered.
53
+ * @param {object} options - probe inputs.
54
+ * @param {string} options.distro - distribution name.
55
+ * @param {string} [options.username] - Linux user; omitted uses the distribution default.
56
+ * @param {(options: object) => Promise<{ stdout: string, exitCode: number | null }>} options.run - command runner.
57
+ * @returns {Promise<{ uid: string, gid: string } | null>} the identity, or null when it cannot be read.
58
+ */
59
+ export async function resolveIdentity({ distro, username, run }) {
60
+ const key = keyFor(distro, username)
61
+ if (identityCache.has(key)) return identityCache.get(key)
62
+ const probe = () => run({
63
+ distro,
64
+ linuxCwd: '/',
65
+ ...username !== undefined && username !== '' ? { username } : {},
66
+ // The home directory is read before `sudo` runs, so it is the session
67
+ // user's; a confined login shell started through `sudo` would otherwise
68
+ // inherit HOME=/root and read the wrong profile.
69
+ command: 'echo __DSH_IDENTITY__; id -u; id -g; printf "%s\\n" "$HOME"; id -un',
70
+ // Non-login + sentinel on purpose: profile scripts must not be able to
71
+ // print identity facts (a model-writable dotfile printing 0/0//root/root
72
+ // would otherwise drop every confined command to root via setpriv) or
73
+ // shift the positional parse. Exactly the marker plus four probe lines is
74
+ // accepted; anything else fails closed for this command. The 60s ceiling
75
+ // plus the one timed-out retry below covers the desktop's first wsl.exe
76
+ // spawn after a restart, which can outlive a short ceiling on a cold VM.
77
+ loginShell: false,
78
+ timeoutMs: 60_000,
79
+ })
80
+ let result = await probe()
81
+ if (result.timedOut) {
82
+ // One transparent retry: the same probe. A second timeout surfaces as the
83
+ // diagnosable parse failure below (timedOut included in the message).
84
+ result = await probe()
85
+ }
86
+ const lines = result.stdout.trim().split('\n')
87
+ const markerAt = lines.indexOf('__DSH_IDENTITY__')
88
+ const [uid, gid, home, name] = markerAt >= 0 && lines.length - markerAt === 5
89
+ ? lines.slice(markerAt + 1)
90
+ : []
91
+ const identity = uid !== undefined && gid !== undefined && home !== undefined
92
+ && /^\d+$/.test(uid) && /^\d+$/.test(gid) && home.startsWith('/')
93
+ ? { uid, gid, home, name: (name ?? '').trim() }
94
+ : null
95
+ // A failed parse is a diagnosable event, not a silent null: surface what the
96
+ // probe actually saw so the caller's SandboxUnavailableError carries the
97
+ // evidence (trimmed) instead of a bare "identity unresolvable".
98
+ if (identity === null) {
99
+ throw new Error(
100
+ `身份探针输出无法解析(exit=${String(result.exitCode)} timedOut=${String(result.timedOut)}):stdout=${JSON.stringify(result.stdout.slice(0, 400))} stderr=${JSON.stringify(result.stderr.slice(0, 200))}`,
101
+ )
102
+ }
103
+ // Cache only a resolved identity, mirroring detectRunner: a transient
104
+ // wsl.exe failure must not stick `null` for the process lifetime and deny
105
+ // every later confined command after the distribution recovered. An
106
+ // uncached failure still fails closed for THIS command; the next one
107
+ // re-probes.
108
+ if (identity !== null) identityCache.set(key, identity)
109
+ return identity
110
+ }
111
+
112
+ /**
113
+ * Detect whether the distribution can run the confinement runner.
114
+ *
115
+ * Probed once per distribution/user pair: the answer cannot change while the
116
+ * process runs, and every confined command would otherwise pay a `sudo` round
117
+ * trip.
118
+ * @param {object} options - probe inputs.
119
+ * @param {string} options.distro - distribution name.
120
+ * @param {string} [options.username] - Linux user.
121
+ * @param {(options: object) => Promise<{ stdout: string }>} options.run - command runner.
122
+ * @returns {Promise<string | null>} the runner id, or null when unavailable.
123
+ */
124
+ export async function detectRunner({ distro, username, run }) {
125
+ const key = keyFor(distro, username)
126
+ if (runnerCache.has(key)) return runnerCache.get(key)
127
+ const probeOnce = async () => {
128
+ try {
129
+ // Prefer the hardened helper when the operator installed it: the
130
+ // sudoers grant is narrowed to the helper alone, which always applies
131
+ // the fence before exec-ing — the retained-grant self-escape is closed.
132
+ const helperProbe = await run({
133
+ distro,
134
+ linuxCwd: '/',
135
+ ...username !== undefined && username !== '' ? { username } : {},
136
+ command: `test -x ${shellQuote(HELPER_PATH)} && sudo -n ${shellQuote(HELPER_PATH)} --version 2>/dev/null | grep -q '^dsh-wsl-confine' && echo yes || echo no`,
137
+ loginShell: false,
138
+ timeoutMs: 60_000,
139
+ })
140
+ if (helperProbe.stdout.trim() === 'yes') return RUNNER_HELPER
141
+ const result = await run({
142
+ distro,
143
+ linuxCwd: '/',
144
+ ...username !== undefined && username !== '' ? { username } : {},
145
+ command: 'sudo -n true >/dev/null 2>&1 && command -v unshare >/dev/null 2>&1 && command -v setpriv >/dev/null 2>&1 && echo yes || echo no',
146
+ // Non-login: with rc output excluded, the answer is exactly 'yes' or
147
+ // 'no' and can be compared strictly instead of by substring. The 60s
148
+ // ceiling matches the identity probe (cold VM first spawn).
149
+ loginShell: false,
150
+ timeoutMs: 60_000,
151
+ })
152
+ return result.stdout.trim() === 'yes' ? RUNNER_SUDO_UNSHARE : null
153
+ } catch {
154
+ // An unreachable distribution is reported by the caller's own failure path.
155
+ return null
156
+ }
157
+ }
158
+ // One transparent retry: the desktop's first wsl.exe spawns after a restart
159
+ // can fail inside the cold-start window (same rationale as the identity
160
+ // probe's retry). The retry repeats the SAME probe; a persistent absence
161
+ // still fails closed for THIS command, uncached.
162
+ let runner = await probeOnce()
163
+ if (runner === null) {
164
+ await new Promise((resolve) => { setTimeout(resolve, 1_000) })
165
+ runner = await probeOnce()
166
+ }
167
+ if (runner !== null) runnerCache.set(key, runner)
168
+ return runner
169
+ }
170
+
171
+ /**
172
+ * Forget cached probe results. Used by tests and by a settings change.
173
+ */
174
+ export function resetConfinementCache() {
175
+ identityCache.clear()
176
+ runnerCache.clear()
177
+ noNewPrivsCache.clear()
178
+ }
179
+
180
+ /**
181
+ * Detect whether the distribution's `setpriv` supports `--no-new-privs`.
182
+ *
183
+ * When supported, the confined drop sets NO_NEW_PRIVS for the whole command
184
+ * subtree: setuid/file-caps elevation fails loudly, which closes the
185
+ * retained-sudo self-escape (the session user keeps a passwordless sudo grant
186
+ * — the same primitive the runner itself uses — and without NNP that grant
187
+ * voids the file fence at will). Probed once per distribution/user pair;
188
+ * availability cannot change while the process runs.
189
+ * @param {object} options - probe inputs.
190
+ * @param {string} options.distro - distribution name.
191
+ * @param {string} [options.username] - Linux user.
192
+ * @param {(options: object) => Promise<{ stdout: string }>} options.run - command runner.
193
+ * @returns {Promise<boolean>} whether the drop can set NO_NEW_PRIVS.
194
+ */
195
+ export async function detectNoNewPrivs({ distro, username, run }) {
196
+ const key = keyFor(distro, username)
197
+ if (noNewPrivsCache.has(key)) return noNewPrivsCache.get(key)
198
+ let supported = false
199
+ try {
200
+ const result = await run({
201
+ distro,
202
+ linuxCwd: '/',
203
+ ...username !== undefined && username !== '' ? { username } : {},
204
+ command: 'setpriv --help 2>&1 | grep -q -- --no-new-privs && echo yes || echo no',
205
+ loginShell: false,
206
+ timeoutMs: 60_000,
207
+ })
208
+ supported = result.stdout.trim() === 'yes'
209
+ } catch {
210
+ supported = false
211
+ }
212
+ noNewPrivsCache.set(key, supported)
213
+ return supported
214
+ }
215
+
216
+ /**
217
+ * Translate a session workspace root into a Linux path.
218
+ * @param {string | undefined} workspaceRoot - the resolved policy's workspace root.
219
+ * @param {string} linuxCwd - the command's Linux working directory.
220
+ * @param {(path: string) => string | null} toLinux - host-to-Linux translator.
221
+ * @returns {string | null} the Linux workspace root, or null when it is not addressable.
222
+ */
223
+ export function workspaceRootInLinux(workspaceRoot, linuxCwd, toLinux) {
224
+ if (workspaceRoot === undefined || workspaceRoot.length === 0) return linuxCwd
225
+ const mapped = toLinux(workspaceRoot)
226
+ return mapped === null || mapped === '' ? null : mapped
227
+ }
228
+
229
+ /**
230
+ * Exit code the confinement script reserves for its own failure.
231
+ *
232
+ * A command that fails and a confinement that could not be established are
233
+ * different outcomes: the caller must be able to tell them apart, because the
234
+ * second one means the command ran under a weaker boundary than promised.
235
+ */
236
+ export const SETUP_FAILURE_EXIT = 97
237
+
238
+ /** stderr marker identifying a confinement setup failure rather than a command failure. */
239
+ export const SETUP_FAILURE_MARKER = 'dsh-wsl-sandbox: setup failed'
240
+
241
+ /**
242
+ * Mounts deliberately left writable: device nodes and kernel state.
243
+ *
244
+ * These are not file storage, so a read-only remount would break the process
245
+ * (writing to `/dev/null` on a read-only devtmpfs fails with `EROFS`) without
246
+ * protecting any file. Exact matches only — `/dev/shm` is a separate tmpfs and
247
+ * IS file storage, so it is remounted read-only like everything else.
248
+ */
249
+ export const KERNEL_SURFACES = new Set(['/dev', '/dev/pts', '/dev/mqueue', '/proc', '/sys'])
250
+
251
+ /**
252
+ * Escape ERE metacharacters so a path can only ever match itself.
253
+ * @param {string} text - a mount target or exempt path.
254
+ * @returns {string} the regex-escaped text.
255
+ */
256
+ function escapeEre(text) {
257
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
258
+ }
259
+
260
+ /**
261
+ * The assembled grep -E pattern for one sweep/postcondition: regex-escaped
262
+ * entries joined with alternation, anchored both ends.
263
+ * @param {string[]} keep - mount targets that stay writable.
264
+ * @returns {string} the pattern, as data (the caller quotes it for the shell).
265
+ */
266
+ function exemptPattern(keep) {
267
+ return `^(${[...new Set([...keep, ...KERNEL_SURFACES])].map(escapeEre).join('|')})$`
268
+ }
269
+
270
+ /**
271
+ * The shell fragment that makes every remaining writable mount read-only.
272
+ *
273
+ * Enumerated at run time from `findmnt` rather than from a fixed list, because a
274
+ * list is only ever as complete as the machine it was written on: a new drvfs
275
+ * drive or a runtime tmpfs would reopen the hole silently.
276
+ * @param {string[]} keep - mount targets that stay writable.
277
+ * @returns {string} the fragment.
278
+ */
279
+ function readOnlySweep(keep) {
280
+ const pattern = exemptPattern(keep)
281
+ return [
282
+ // Line-driven, not word-split: `$(…)` in a for-loop would split a mount
283
+ // target containing whitespace into two bogus targets. The pattern is ONE
284
+ // shellQuoted word over regex-escaped entries, so shell/regex
285
+ // metacharacters in a path stay inert data. A failing grep (invalid ERE,
286
+ // unexpected errors) calls fail() — exit 97 inside the pipeline's subshell
287
+ // reaches the script through `set -o pipefail` — never a vacuous pass.
288
+ //
289
+ // findmnt -r hex-escapes unsafe characters in TARGET (\x20 space, \x09
290
+ // tab, \x0a newline, \x5c backslash) — a spaced target swept under its
291
+ // escaped literal name would ENOENT the remount (swallowed below) and
292
+ // stay WRITABLE while the postcondition tested the bogus name. Decode
293
+ // before matching: findmnt only ever emits \xNN for literal characters
294
+ // (literal backslash arrives as \x5c), so bash's %b is lossless here.
295
+ `findmnt -rno TARGET | while IFS= read -r raw; do printf '%b\\n' "$raw"; done | { grep -Ev ${shellQuote(pattern)} || fail ${shellQuote('exemption grep failed')}; } | while IFS= read -r target; do`,
296
+ ' mount -o remount,ro,bind "$target" >/dev/null 2>&1 || true;',
297
+ 'done',
298
+ ].join('\n')
299
+ }
300
+
301
+ /**
302
+ * The post-condition: the namespace must be what was asked for.
303
+ *
304
+ * Every step above tolerates its own failure so that one unmountable target does
305
+ * not abort the rest, which means the only trustworthy statement about the
306
+ * boundary is one read back from inside it. A target that is still writable
307
+ * outside the allow-list is a setup failure, not a command failure.
308
+ * @param {string[]} keep - mount targets that may stay writable.
309
+ * @returns {string} the fragment.
310
+ */
311
+ function postConditions(keep) {
312
+ const pattern = exemptPattern(keep)
313
+ return [
314
+ 'mountpoint -q /tmp || fail "/tmp is not a private tmpfs"',
315
+ 'findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"',
316
+ // Same line-driven loop, decode-before-match, and single-quoted pattern
317
+ // as the sweep (fail() is defined in the setup steps). The trailing
318
+ // `true` matters: the while is the LAST element of a pipeline, so a body
319
+ // that ends with a failing `[ -w ]` (the normal case — every swept target
320
+ // IS read-only) would otherwise fail the whole pipeline under
321
+ // `set -o pipefail` and abort the script before the command runs. A real
322
+ // `fail` still exits 97 from inside the loop, which pipefail turns into
323
+ // the script's own setup failure — the marker and exit code still
324
+ // surface.
325
+ `findmnt -rno TARGET | while IFS= read -r raw; do printf '%b\\n' "$raw"; done | { grep -Ev ${shellQuote(pattern)} || fail ${shellQuote('exemption grep failed')}; } | while IFS= read -r target; do`,
326
+ ' [ -w "$target" ] && fail "$target is still writable";',
327
+ ' true;',
328
+ 'done',
329
+ 'true',
330
+ ].join('\n')
331
+ }
332
+
333
+ /**
334
+ * Build the inner script that establishes the confinement and runs the command.
335
+ *
336
+ * Order matters: the writable paths are bound *before* the root is remounted
337
+ * read-only, because a bind created afterwards inherits the read-only state.
338
+ *
339
+ * The script fails closed. It runs under `set -euo pipefail`, every step is a
340
+ * separate statement whose failure aborts it, and it ends by verifying the
341
+ * boundary it just built — a failed `mount` used to leave the command running
342
+ * with a writable root while the caller was told the sandbox was fully enforced.
343
+ * @param {object} options - confinement plan inputs.
344
+ * @param {string} options.command - the caller's shell source.
345
+ * @param {string} options.linuxCwd - absolute Linux working directory.
346
+ * @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
347
+ * @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
348
+ * @param {{ uid: string, gid: string }} options.identity - identity to drop back to.
349
+ * @returns {string} the script to run inside the namespace.
350
+ */
351
+ export function buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs }) {
352
+ const keep = ['/tmp']
353
+ const steps = ['set -euo pipefail']
354
+ // The failure helper is defined before the sweep so BOTH the sweep and the
355
+ // postcondition can fail closed through it.
356
+ steps.push(`fail() { printf '%s: %s\\n' ${shellQuote(SETUP_FAILURE_MARKER)} "$1" >&2; exit ${SETUP_FAILURE_EXIT}; }`)
357
+ if (mode === 'workspace-write') {
358
+ if (workspaceLinuxRoot === undefined || workspaceLinuxRoot === '') {
359
+ throw new Error('wsl-sandbox: workspace-write 需要一个工作区根目录')
360
+ }
361
+ // The workspace may not be a mount point, so the bind is its own source and
362
+ // target; the following remount of `/` leaves this nested mount writable.
363
+ steps.push(`mount --bind ${shellQuote(workspaceLinuxRoot)} ${shellQuote(workspaceLinuxRoot)}`)
364
+ keep.push(workspaceLinuxRoot)
365
+ }
366
+ steps.push('mount -t tmpfs tmpfs /tmp')
367
+ steps.push('mount -o remount,ro,bind /')
368
+ steps.push(readOnlySweep(keep))
369
+ steps.push(postConditions(keep))
370
+ const inner = `cd ${shellQuote(linuxCwd)} && ${command}`
371
+ // `sudo` leaves HOME pointing at root; without restoring it a login shell
372
+ // reads the wrong profile and reports "Permission denied" on every command.
373
+ const environment = [`HOME=${shellQuote(identity.home)}`]
374
+ if (identity.name !== undefined && identity.name !== '') environment.push(`USER=${shellQuote(identity.name)}`, `LOGNAME=${shellQuote(identity.name)}`)
375
+ // NO_NEW_PRIVS on the drop (when the distro's setpriv supports it) blocks
376
+ // setuid/file-caps elevation for the whole command subtree — without it the
377
+ // session user's retained passwordless sudo grant re-invokes the very
378
+ // privileged primitive this fence was built from (unfenced unshare, or
379
+ // remount,rw inside the namespace), voiding the file fence at will.
380
+ const nnp = noNewPrivs === true ? '--no-new-privs ' : ''
381
+ const drop = `setpriv ${nnp}--reuid=${identity.uid} --regid=${identity.gid} --init-groups `
382
+ + `env ${environment.join(' ')} bash -lc ${shellQuote(inner)}`
383
+ steps.push(`exec ${drop}`)
384
+ return steps.join('\n')
385
+ }
386
+
387
+ /**
388
+ * Build the command the executor hands to the distribution's outer shell.
389
+ * @param {object} options - confinement plan inputs.
390
+ * @param {string} options.command - the caller's shell source.
391
+ * @param {string} options.linuxCwd - absolute Linux working directory.
392
+ * @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
393
+ * @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
394
+ * @param {{ uid: string, gid: string }} options.identity - identity to drop back to.
395
+ * @param {boolean} [options.noNewPrivs] - set NO_NEW_PRIVS on the drop when the
396
+ * distro's setpriv supports it (blocks setuid elevation inside the fence).
397
+ * @param {string} [options.runner] - the resolved runner: 'helper' routes the
398
+ * command through the root-owned dsh-wsl-confine helper (the hardened path);
399
+ * 'sudo-unshare' (default) keeps the direct sudo-unshare runner.
400
+ * @param {boolean} [options.isolateProcesses] - add a PID namespace.
401
+ * @returns {string} the outer shell command.
402
+ */
403
+ export function buildConfinedCommand({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs, runner, isolateProcesses = true }) {
404
+ // The hardened helper path: the root-owned helper applies the identical
405
+ // fence itself and setpriv-exec's the command with NO_NEW_PRIVS — the
406
+ // command text travels as argv after `--` and is never evaluated as root.
407
+ if (runner === RUNNER_HELPER) {
408
+ const flags = [
409
+ `--uid ${identity.uid}`,
410
+ `--gid ${identity.gid}`,
411
+ `--home ${shellQuote(identity.home)}`,
412
+ `--cwd ${shellQuote(linuxCwd)}`,
413
+ ...(workspaceLinuxRoot !== undefined && workspaceLinuxRoot !== '' ? [`--workspace ${shellQuote(workspaceLinuxRoot)}`] : []),
414
+ ...(isolateProcesses ? [] : ['--no-pidns']),
415
+ '--',
416
+ shellQuote(command),
417
+ ]
418
+ return `sudo -n ${shellQuote(HELPER_PATH)} ${flags.join(' ')}`
419
+ }
420
+ const script = buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs })
421
+ const flags = ['--mount', '--propagation', 'private']
422
+ if (isolateProcesses) flags.push('--pid', '--fork')
423
+ return `sudo -n unshare ${flags.join(' ')} bash -c ${shellQuote(script)}`
424
+ }
@@ -0,0 +1,80 @@
1
+ #!/bin/bash
2
+ # dsh-wsl-confine v1 — DSH WSL confinement helper.
3
+ #
4
+ # Root-owned fence executor: the ONLY thing this helper does is apply the
5
+ # mount-namespace fence (workspace bind, tmpfs /tmp, read-only /, read-only
6
+ # sweep with postconditions) and then drop to the target user and exec their
7
+ # command. The sudoers grant is narrowed to this helper alone, so re-invoking
8
+ # the privileged primitive from inside a confined command can only re-fence
9
+ # from the already-fenced context — the fence is no longer voidable by the
10
+ # principal it constrains.
11
+ #
12
+ # Install (one-time, per distribution, as root):
13
+ # install -m 0755 -o root -g root <this file> /usr/local/sbin/dsh-wsl-confine
14
+ # echo '<session-user> ALL=(root) NOPASSWD: /usr/local/sbin/dsh-wsl-confine *' \
15
+ # > /etc/sudoers.d/dsh-wsl-confine && chmod 0440 /etc/sudoers.d/dsh-wsl-confine
16
+ #
17
+ # Contract: parameters are strictly validated (numeric uid/gid, absolute
18
+ # paths); the caller's command travels as argv after `--` and is executed only
19
+ # AFTER the setpriv drop (with NO_NEW_PRIVS) — root never evaluates caller text.
20
+ set -euo pipefail
21
+ VERSION='dsh-wsl-confine v1'
22
+ SETUP_FAILURE_EXIT=97
23
+
24
+ uid=; gid=; home=; cwd=; workspace=; pidns=1
25
+ ARGS=()
26
+ while (($#)); do
27
+ case "$1" in
28
+ --uid) uid="${2:-}"; shift 2 ;;
29
+ --gid) gid="${2:-}"; shift 2 ;;
30
+ --home) home="${2:-}"; shift 2 ;;
31
+ --cwd) cwd="${2:-}"; shift 2 ;;
32
+ --workspace) workspace="${2:-}"; shift 2 ;;
33
+ --no-pidns) pidns=0; shift ;;
34
+ --version) echo "$VERSION"; exit 0 ;;
35
+ --) shift; ARGS=("$@"); break ;;
36
+ *) echo "dsh-wsl-confine: unknown argument: $1" >&2; exit 2 ;;
37
+ esac
38
+ done
39
+
40
+ [[ "$uid" =~ ^[0-9]+$ && "$gid" =~ ^[0-9]+$ ]] || { echo 'dsh-wsl-confine: uid/gid must be numeric' >&2; exit 2; }
41
+ [[ -n "$cwd" && "$cwd" = /* && -n "$home" && "$home" = /* ]] || { echo 'dsh-wsl-confine: --cwd/--home must be absolute Linux paths' >&2; exit 2; }
42
+ [[ -z "$workspace" || "$workspace" = /* ]] || { echo 'dsh-wsl-confine: --workspace must be absolute' >&2; exit 2; }
43
+ ((${#ARGS[@]} >= 1)) || { echo 'dsh-wsl-confine: no command' >&2; exit 2 }
44
+ command -v unshare >/dev/null 2>&1 || { echo 'dsh-wsl-confine: unshare not found' >&2; exit 97; }
45
+ command -v setpriv >/dev/null 2>&1 || { echo 'dsh-wsl-confine: setpriv not found' >&2; exit 97; }
46
+ command -v findmnt >/dev/null 2>&1 || { echo 'dsh-wsl-confine: findmnt not found' >&2; exit 97; }
47
+
48
+ # The fence script is built HERE from the validated parameters — the caller
49
+ # never supplies script text. It is identical in effect to the in-process
50
+ # builder (bind workspace before ro, tmpfs /tmp, decoded sweep, postconditions,
51
+ # NO_NEW_PRIVS drop).
52
+ FENCE='set -euo pipefail
53
+ fail() { printf "%s: %s\n" "dsh-wsl-confine" "$1" >&2; exit 97; }
54
+ if [[ -n "${WORKSPACE:-}" ]]; then
55
+ mount --bind "$WORKSPACE" "$WORKSPACE"
56
+ KEEP=("/tmp" "$WORKSPACE")
57
+ else
58
+ KEEP=("/tmp")
59
+ fi
60
+ mount -t tmpfs tmpfs /tmp
61
+ mount -o remount,ro,bind /
62
+ findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | { grep -Ev "^($(printf "%s|" "${KEEP[@]}" | sed "s/|$//")|/dev$|/proc$|/sys$")$" || fail "exemption grep failed"; } | while IFS= read -r target; do
63
+ mount -o remount,ro,bind "$target" >/dev/null 2>&1 || true
64
+ done
65
+ mountpoint -q /tmp || fail "/tmp is not a private tmpfs"
66
+ findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"
67
+ findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | { grep -Ev "^($(printf "%s|" "${KEEP[@]}" | sed "s/|$//")|/dev$|/proc$|/sys$")$" || fail "exemption grep failed"; } | while IFS= read -r target; do
68
+ [[ -w "$target" ]] && fail "$target is still writable"
69
+ true
70
+ done
71
+ cd "$CWD"
72
+ exec setpriv --no-new-privs --reuid="$UID" --regid="$GID" --init-groups env HOME="$HOME_DIR" USER="$USER_NAME" LOGNAME="$USER_NAME" bash -lc "$COMMAND"'
73
+
74
+ PID_FLAG=''
75
+ [[ "$pidns" = 1 ]] && PID_FLAG='--pid --fork'
76
+
77
+ exec unshare --mount --propagation private $PID_FLAG bash -c "$FENCE" \
78
+ dsh-wsl-confine \
79
+ UID="$uid" GID="$gid" HOME_DIR="$home" USER_NAME="${SUDO_USER:-user}" \
80
+ CWD="$cwd" WORKSPACE="$workspace" COMMAND="${ARGS[*]}"
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Pure containment mechanics for the WSL filesystem fence.
3
+ *
4
+ * The fence itself lives on `WslFileSystem` (`./fs.js`), but the comparison
5
+ * and allow-list derivation are path algebra with no harness dependency, so
6
+ * they live here where a plain Node process can load and verify them (the same
7
+ * split as `./paths.js` and `./confinement.js`).
8
+ *
9
+ * The rule mirrors the shipped backend (`@deepseek-ai/dsh-fs-sandbox` +
10
+ * `@deepseek-ai/dsh-sandbox/roots`), translated into this world: comparison
11
+ * happens in the HOST namespace, because a targetKey is a Windows spelling and
12
+ * its UNC prefix carries the distribution — same-Linux-path targets of another
13
+ * distro stay outside, and the Windows temp dir (reachable through
14
+ * `/mnt/<drive>`) can still be a granted root.
15
+ * @module dsh-wsl-desktop/wsl/fence
16
+ */
17
+
18
+ import { stat } from 'node:fs/promises'
19
+ import { realpathSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { win32 } from 'node:path'
22
+ import { joinWslUnc } from './paths.js'
23
+
24
+ /**
25
+ * Canonicalize a writable root the way the shipped derivation does
26
+ * (`@deepseek-ai/dsh-sandbox/roots`): a root that cannot be resolved stays as
27
+ * spelled, which matches nothing until it exists — the conservative outcome.
28
+ * @param {string} path - the root as configured or platform-reported.
29
+ * @returns {string} the canonical path, or the input when resolution fails.
30
+ */
31
+ export function canonicalHostPath(path) {
32
+ try {
33
+ return realpathSync.native(path)
34
+ } catch {
35
+ return path
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Lexical containment with a separator boundary, case-insensitive because
41
+ * targetKeys are Windows spellings (`\\wsl.localhost\…` or a drive path). The
42
+ * boundary is what stops `/proj` matching `/proj-secret`.
43
+ * @param {string} targetKey - the canonical host path of the candidate.
44
+ * @param {string} root - the canonical host path of the writable root.
45
+ * @returns {boolean} true when the target is the root or below it.
46
+ */
47
+ export function isLexicallyUnderHost(targetKey, root) {
48
+ const target = targetKey.toLowerCase()
49
+ const prefix = root.toLowerCase()
50
+ if (target === prefix) return true
51
+ const bounded = prefix.endsWith('\\') || prefix.endsWith('/') ? prefix : `${prefix}\\`
52
+ return target.startsWith(bounded)
53
+ }
54
+
55
+ const MISSING_CODES = new Set(['ENOENT', 'ENOTDIR'])
56
+
57
+ /**
58
+ * Record one stat outcome, distinguishing "absent" from a real I/O fault.
59
+ * @param {string} path - the path to stat.
60
+ * @returns {Promise<object | undefined>} the bigint stats, or undefined when absent.
61
+ */
62
+ async function statIfPresent(path) {
63
+ try {
64
+ return await stat(path, { bigint: true })
65
+ } catch (error) {
66
+ if (MISSING_CODES.has(error?.code)) return undefined
67
+ throw error
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Containment for the fs fence: the lexical fast path over canonical
73
+ * spellings, then a filesystem-identity walk that recognizes alias-equivalent
74
+ * roots (casing, short names) without weakening containment to a textual
75
+ * approximation. Mirrors `@deepseek-ai/dsh-fs-sandbox/containment`.
76
+ * @param {string} targetKey - the canonical host path of the candidate.
77
+ * @param {string} root - the canonical host path of the writable root.
78
+ * @returns {Promise<boolean>} true when the target is the root or below it.
79
+ */
80
+ export async function isUnderHost(targetKey, root) {
81
+ if (isLexicallyUnderHost(targetKey, root)) return true
82
+ const rootInfo = await statIfPresent(root)
83
+ if (rootInfo === undefined) return false
84
+ let ancestor = targetKey
85
+ for (;;) {
86
+ const info = await statIfPresent(ancestor)
87
+ if (info !== undefined && info.dev === rootInfo.dev && info.ino === rootInfo.ino) return true
88
+ const parent = win32.dirname(ancestor)
89
+ if (parent === ancestor) return false
90
+ ancestor = parent
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The roots one workspace-write mutation may land under, in the host spellings
96
+ * targetKeys use. This mirrors `writableRoots` translated into this world: the
97
+ * workspace root, the *distribution's* temp area on the share (what a Linux
98
+ * `/tmp/…` request resolves to here — the shipped derivation's POSIX `/tmp`
99
+ * entry is meaningless on a Windows host), and the Windows temp dir, reachable
100
+ * through `/mnt/<drive>`.
101
+ *
102
+ * Like the shipped derivation, only `workspace-write` grants roots: an unknown
103
+ * mode yields an empty allow-list, which denies — fail-closed.
104
+ * @param {{ mode?: string, workspaceRoot?: string }} policy - the per-call policy.
105
+ * @param {string} distro - the distribution this backend is pinned to.
106
+ * @returns {string[]} canonical host paths; empty unless workspace-write.
107
+ */
108
+ export function writableHostRootsFor(policy, distro) {
109
+ if (policy?.mode !== 'workspace-write') return []
110
+ // joinWslUnc throws for an invalid distro name; converting the throw to a
111
+ // filtered-out entry keeps this function total (never throws) so the caller
112
+ // sees an empty allow-list (fail-closed) rather than a raw Error escaping
113
+ // checkedTarget as a non-sandbox failure.
114
+ const distroTmp = (() => { try { return joinWslUnc(distro, '/tmp') } catch { return null } })()
115
+ const roots = [policy.workspaceRoot, distroTmp, tmpdir()]
116
+ return [...new Set(
117
+ roots
118
+ .filter((root) => typeof root === 'string' && root.length > 0)
119
+ .map(canonicalHostPath),
120
+ )]
121
+ }