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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +709 -0
- package/lib/http-admission.js +102 -0
- package/lib/index.js +1067 -0
- package/lib/wsl/confinement.js +424 -0
- package/lib/wsl/dsh-wsl-confine.sh +80 -0
- package/lib/wsl/fence.js +121 -0
- package/lib/wsl/fs.js +324 -0
- package/lib/wsl/host-refs.js +38 -0
- package/lib/wsl/paths.js +95 -0
- package/lib/wsl/preset.js +456 -0
- package/lib/wsl/pty.js +366 -0
- package/lib/wsl/shell.js +462 -0
- package/lib/wsl/subprocess.js +195 -0
- package/lib/wsl/terminal-bridge.py +278 -0
- package/lib/wsl/world.js +458 -0
- package/package.json +35 -0
|
@@ -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[*]}"
|
package/lib/wsl/fence.js
ADDED
|
@@ -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
|
+
}
|