dsh-wsl-desktop 0.7.3 → 0.7.5
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/lib/client.js +2 -2
- package/lib/index.js +49 -21
- package/lib/protocol.js +2 -2
- package/lib/wsl/confinement.js +166 -15
- package/lib/wsl/dsh-wsl-confine.sh +63 -8
- package/lib/wsl/fs.js +32 -0
- package/lib/wsl/sandbox.js +11 -4
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -245,7 +245,7 @@ window.__ModuleLoader__.load({
|
|
|
245
245
|
'unmeasured': '未测量',
|
|
246
246
|
'not measured': '未测量',
|
|
247
247
|
'not selected; the runner uses sudo-unshare': '未被选中;运行器使用 sudo-unshare',
|
|
248
|
-
'not installed or not v1.
|
|
248
|
+
'not installed or not v1.3': '未安装或版本不是 v1.3',
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/**
|
|
@@ -256,7 +256,7 @@ window.__ModuleLoader__.load({
|
|
|
256
256
|
function localizeDetail(detail) {
|
|
257
257
|
if (LOCALE !== 'zh' || typeof detail !== 'string') return detail
|
|
258
258
|
if (detail.startsWith('missing: ')) return `${copy('missingPrefix')}${detail.slice('missing: '.length)}`
|
|
259
|
-
// Version strings carry their own identity (Python x.y, dsh-wsl-confine v1.
|
|
259
|
+
// Version strings carry their own identity (Python x.y, dsh-wsl-confine v1.3).
|
|
260
260
|
if (detail.startsWith('Python ') || detail.startsWith('dsh-wsl-confine ')) return detail
|
|
261
261
|
return DETAIL_ZH[detail] ?? detail
|
|
262
262
|
}
|
package/lib/index.js
CHANGED
|
@@ -782,11 +782,17 @@ const METHODS = {
|
|
|
782
782
|
// Created through the shell this block already runs its mutations with. A create
|
|
783
783
|
// that FAILED must not read as a false green, so its outcome is reported in the
|
|
784
784
|
// row: an absent path would notify from its arming baseline and pass on nothing.
|
|
785
|
-
|
|
785
|
+
// The path is shellQuote'd, not interpolated inside hand-written quotes: the Linux
|
|
786
|
+
// path grammar refuses control characters and backslashes but NOT an apostrophe
|
|
787
|
+
// (measured), so `'${path}'` would end the word early and the remainder would run
|
|
788
|
+
// as shell syntax. Every other command in this file already goes through the
|
|
789
|
+
// helper; these four were the exception.
|
|
790
|
+
const probePath = shellQuote(fs.processPath(fileTarget))
|
|
791
|
+
const probeCreated = await runShellSpec(shell, shell.resolve({ command: `printf 'x\\n' > ${probePath}`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
|
|
786
792
|
.then((execution) => execution.result())
|
|
787
793
|
.catch((error) => ({ exitCode: null, stderr: { text: String(error?.message ?? error) } }))
|
|
788
794
|
record('fs.watch.file', probeCreated.exitCode === 0
|
|
789
|
-
? await observeWatch(fileTarget, `printf 'x\\n' >>
|
|
795
|
+
? await observeWatch(fileTarget, `printf 'x\\n' >> ${probePath}`)
|
|
790
796
|
: {
|
|
791
797
|
notified: 0,
|
|
792
798
|
watchError: `the probe file could not be created: exit ${probeCreated.exitCode ?? 'null'} ${probeCreated.stderr?.text?.slice(0, 200) ?? ''}`.trim(),
|
|
@@ -794,9 +800,10 @@ const METHODS = {
|
|
|
794
800
|
})
|
|
795
801
|
|
|
796
802
|
const dirTarget = await fs.resolve('.', { cwd: workspaceCwd })
|
|
797
|
-
const
|
|
803
|
+
const dirEntry = shellQuote(`${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt`)
|
|
804
|
+
const dirCommand = `printf 'x\\n' > ${dirEntry}`
|
|
798
805
|
record('fs.watch.dir', await observeWatch(dirTarget, dirCommand))
|
|
799
|
-
await runShellSpec(shell, shell.resolve({ command: `rm -f
|
|
806
|
+
await runShellSpec(shell, shell.resolve({ command: `rm -f ${dirEntry}`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
|
|
800
807
|
.then((execution) => execution.result()).catch(() => {})
|
|
801
808
|
|
|
802
809
|
// Why the override exists, recorded rather than assumed: if a future host or
|
|
@@ -847,6 +854,18 @@ const METHODS = {
|
|
|
847
854
|
const tools = hostSession.ctx.get('tools')
|
|
848
855
|
if (tools !== undefined && agent !== undefined) {
|
|
849
856
|
const controller = new AbortController()
|
|
857
|
+
/**
|
|
858
|
+
* The text one tool outcome reports, capped for the receipt.
|
|
859
|
+
*
|
|
860
|
+
* The `.text`-first rule lives here rather than repeated at seven call sites: every
|
|
861
|
+
* row wants the tool's OWN output, and falls back to the serialized result only when
|
|
862
|
+
* the outcome carries no text (a failure has no `value` to serialize). The cap
|
|
863
|
+
* differs per row because the rows read different amounts of that output.
|
|
864
|
+
* @param {{ text?: string, result?: unknown }} outcome - one `invoke` result.
|
|
865
|
+
* @param {number} limit - maximum characters kept.
|
|
866
|
+
* @returns {string} the text to record.
|
|
867
|
+
*/
|
|
868
|
+
const outcomeText = (outcome, limit) => (outcome.text ?? JSON.stringify(outcome.result ?? null)).slice(0, limit)
|
|
850
869
|
/**
|
|
851
870
|
* Execute one tool on the session's behalf.
|
|
852
871
|
* @param {string} toolName - registered tool name.
|
|
@@ -875,18 +894,27 @@ const METHODS = {
|
|
|
875
894
|
})
|
|
876
895
|
const outcome = batch?.results?.[0]
|
|
877
896
|
if (outcome === undefined) return { ok: false, error: 'the batch returned no result for this call' }
|
|
878
|
-
// `ToolCallResult` is `{ callId, result: ToolExecutionResult }`, and
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
897
|
+
// `ToolCallResult` is `{ callId, result: ToolExecutionResult }`, and
|
|
898
|
+
// `ToolExecutionResult` is a DISCRIMINATED UNION on `isError`: a success carries
|
|
899
|
+
// `{ isError: false, value }` and a failure carries `{ isError: true, error }`
|
|
900
|
+
// where `error` is a `ToolFailure { message, info? }` and `value` is `never`.
|
|
901
|
+
// Switching on the discriminant is the contract; probing for `error` on a
|
|
902
|
+
// success is reading a field the type says cannot be there.
|
|
903
|
+
const execution = outcome.result
|
|
904
|
+
if (execution === undefined || execution === null) {
|
|
905
|
+
return { ok: false, error: 'the batch returned a result-shaped null for this call', text: 'null' }
|
|
906
|
+
}
|
|
907
|
+
// Branch ONCE on the discriminant. A failure has no `value` to serialize, so its
|
|
908
|
+
// own message is what the row reports as text — which is also what makes a failed
|
|
909
|
+
// row readable instead of showing `"null"`.
|
|
910
|
+
if (execution.isError === true) {
|
|
911
|
+
const message = String(execution.error?.message ?? 'the tool reported an error')
|
|
912
|
+
return { ok: false, error: message, text: message }
|
|
913
|
+
}
|
|
884
914
|
const text = typeof execution.value === 'string'
|
|
885
915
|
? execution.value
|
|
886
|
-
: JSON.stringify(execution.value ??
|
|
887
|
-
return
|
|
888
|
-
? { ok: false, error: String(execution.error?.message ?? execution.error ?? 'the tool reported an error'), text }
|
|
889
|
-
: { ok: true, result: JSON.parse(JSON.stringify(execution ?? null)), text }
|
|
916
|
+
: JSON.stringify(execution.value ?? null)
|
|
917
|
+
return { ok: true, result: JSON.parse(JSON.stringify(execution)), text }
|
|
890
918
|
} catch (error) {
|
|
891
919
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
892
920
|
}
|
|
@@ -895,7 +923,7 @@ const METHODS = {
|
|
|
895
923
|
record('tools.bash', {
|
|
896
924
|
ok: bashTool.ok,
|
|
897
925
|
error: bashTool.error ?? null,
|
|
898
|
-
text: (bashTool
|
|
926
|
+
text: outcomeText(bashTool, 400),
|
|
899
927
|
})
|
|
900
928
|
// Derived from the workspace, not hardcoded: the fixture used to live at
|
|
901
929
|
// /tmp/dsh-wsl-selftest.txt, which only worked while the workspace was
|
|
@@ -913,13 +941,13 @@ const METHODS = {
|
|
|
913
941
|
record('tools.read', {
|
|
914
942
|
ok: readTool.ok,
|
|
915
943
|
error: readTool.error ?? null,
|
|
916
|
-
text: (readTool
|
|
944
|
+
text: outcomeText(readTool, 400),
|
|
917
945
|
})
|
|
918
946
|
const pwshTool = await invoke('pwsh', { command: 'echo host-side', description: 'selftest: this tool must not exist in a WSL session' })
|
|
919
947
|
record('tools.pwsh', {
|
|
920
948
|
ok: pwshTool.ok,
|
|
921
949
|
error: pwshTool.error ?? null,
|
|
922
|
-
text: (pwshTool
|
|
950
|
+
text: outcomeText(pwshTool, 200),
|
|
923
951
|
})
|
|
924
952
|
|
|
925
953
|
// The realm's own `grep`/`glob`: the host removes both names from a WSL world
|
|
@@ -995,13 +1023,13 @@ const METHODS = {
|
|
|
995
1023
|
record('tool.search.grep', {
|
|
996
1024
|
ok: grepSearch.ok && searchFixtureError === null,
|
|
997
1025
|
error: grepSearch.error ?? searchFixtureError,
|
|
998
|
-
text: (grepSearch
|
|
1026
|
+
text: outcomeText(grepSearch, 800),
|
|
999
1027
|
})
|
|
1000
1028
|
const globSearch = await invoke('glob', { pattern: 'src/**/*.ts' })
|
|
1001
1029
|
record('tool.search.glob', {
|
|
1002
1030
|
ok: globSearch.ok && searchFixtureError === null,
|
|
1003
1031
|
error: globSearch.error ?? searchFixtureError,
|
|
1004
|
-
text: (globSearch
|
|
1032
|
+
text: outcomeText(globSearch, 800),
|
|
1005
1033
|
})
|
|
1006
1034
|
|
|
1007
1035
|
// A tool resolves its policy from the session's logged permission state
|
|
@@ -1058,7 +1086,7 @@ const METHODS = {
|
|
|
1058
1086
|
record('tools.bashPersist.a', {
|
|
1059
1087
|
ok: persistA.ok,
|
|
1060
1088
|
error: persistA.error ?? null,
|
|
1061
|
-
text: (persistA
|
|
1089
|
+
text: outcomeText(persistA, 400),
|
|
1062
1090
|
})
|
|
1063
1091
|
const persistB = await invoke('bash', {
|
|
1064
1092
|
command: `pwd; echo "$DSH_WSL_PERSIST_PROBE"; echo probe > "$HOME/.dsh-wsl-persist-outside"; echo write-exit=$?`,
|
|
@@ -1067,7 +1095,7 @@ const METHODS = {
|
|
|
1067
1095
|
record('tools.bashPersist.b', {
|
|
1068
1096
|
ok: persistB.ok,
|
|
1069
1097
|
error: persistB.error ?? null,
|
|
1070
|
-
text: (persistB
|
|
1098
|
+
text: outcomeText(persistB, 800),
|
|
1071
1099
|
})
|
|
1072
1100
|
|
|
1073
1101
|
// F8's guard, observed from the realm: once a persistent terminal session exists, a
|
package/lib/protocol.js
CHANGED
|
@@ -202,8 +202,8 @@ export const CHECK_REMEDIES = deepFreeze({
|
|
|
202
202
|
},
|
|
203
203
|
helper: {
|
|
204
204
|
kind: 'text',
|
|
205
|
-
zh: 'helper 未安装或版本不是 v1.
|
|
206
|
-
en: 'The helper is missing or not v1.
|
|
205
|
+
zh: 'helper 未安装或版本不是 v1.3;安装说明见 docs/CONFINEMENT.md。',
|
|
206
|
+
en: 'The helper is missing or not v1.3; see docs/CONFINEMENT.md for the install command.',
|
|
207
207
|
},
|
|
208
208
|
nnp: {
|
|
209
209
|
kind: 'text',
|
package/lib/wsl/confinement.js
CHANGED
|
@@ -39,8 +39,20 @@ export const HELPER_PATH = '/usr/local/sbin/dsh-wsl-confine'
|
|
|
39
39
|
* bypass. The probe below therefore refuses anything older, and the direct
|
|
40
40
|
* sudo-unshare runner is used instead: its in-process builder always escaped, so
|
|
41
41
|
* a stale install degrades to a correct fence rather than a silent hole.
|
|
42
|
+
*
|
|
43
|
+
* v1.3 is the first helper whose writability post-condition reads the MOUNT FLAG
|
|
44
|
+
* instead of `[ -w ]`. v1.2 asked `[ -w ]` about every swept target, which the
|
|
45
|
+
* kernel answers "writable" for any non-storage inode — a SOCKET, FIFO or device
|
|
46
|
+
* node — even on a mount that was successfully remounted read-only. With a
|
|
47
|
+
* unix-socket bind mount present (measured: `/mnt/wsl/podman-sockets` while
|
|
48
|
+
* `podman machine` runs) that refused EVERY confined command with exit 97,
|
|
49
|
+
* including a bare `true`, and the terminal path surfaced it only as
|
|
50
|
+
* "PTY shell exited during startup" (github issue #2). Refusing the old version
|
|
51
|
+
* is what makes the fix reach an installed helper: the requirement is an exact
|
|
52
|
+
* match, so an un-reinstalled v1.2 now falls back to the direct runner, whose
|
|
53
|
+
* in-process builder carries the corrected post-condition.
|
|
42
54
|
*/
|
|
43
|
-
export const HELPER_VERSION = 'v1.
|
|
55
|
+
export const HELPER_VERSION = 'v1.3'
|
|
44
56
|
|
|
45
57
|
/** stderr text the kernel produces for a write blocked by the read-only root. */
|
|
46
58
|
export const DENIAL_SIGNATURES = ['Read-only file system', 'read-only file system']
|
|
@@ -555,6 +567,40 @@ export const SETUP_FAILURE_MARKER = 'dsh-wsl-sandbox: setup failed'
|
|
|
555
567
|
*/
|
|
556
568
|
export const KERNEL_SURFACES = new Set(['/dev', '/dev/pts', '/dev/mqueue', '/proc', '/sys'])
|
|
557
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Kernel surfaces exempt as SUBTREES, not as single mount points.
|
|
572
|
+
*
|
|
573
|
+
* A fresh procfs brings its own nested mounts with it, and the distribution's
|
|
574
|
+
* real ones are not theoretical: measured on a running WSL2 distribution,
|
|
575
|
+
* `findmnt` lists `/proc/sys/fs/binfmt_misc` AND
|
|
576
|
+
* `/proc/sys/fs/binfmt_misc/status` as separate mounts, plus a `/proc` record
|
|
577
|
+
* that is a re-mount of the root procfs. A nested kernel mount cannot be made
|
|
578
|
+
* read-only — measured: `mount -o remount,ro,bind /proc/sys/fs/binfmt_misc`
|
|
579
|
+
* answers exit **32** ("mount point not mounted or bad option") on this
|
|
580
|
+
* distribution, and `umount` answers EBUSY — so exact-entry matching left those
|
|
581
|
+
* children unexempt and the post-condition found the first one and refused
|
|
582
|
+
* EVERY confined command (measured: exit 97, `/proc/sys/fs/binfmt_misc is still
|
|
583
|
+
* writable`).
|
|
584
|
+
*
|
|
585
|
+
* This set must agree with `KERNEL_SUBTREE` in `dsh-wsl-confine.sh`, which has
|
|
586
|
+
* carried the same rule since the two fences were split. It did NOT before: the
|
|
587
|
+
* helper exempted these subtrees and the in-process builder matched exactly, so
|
|
588
|
+
* on a distribution with nested kernel mounts the TWO FENCES DISAGREED about
|
|
589
|
+
* whether the fence could be established at all — the helper's path worked while
|
|
590
|
+
* the direct `sudo-unshare` runner failed closed on every command. The direct
|
|
591
|
+
* runner is exactly what an operator gets when the helper is not installed or
|
|
592
|
+
* its version does not match, so that divergence was a whole-world outage for
|
|
593
|
+
* this distribution, not an edge case.
|
|
594
|
+
*
|
|
595
|
+
* Deliberately NARROWER than `KERNEL_SURFACES`: `/dev` and `/tmp` stay exact
|
|
596
|
+
* matches, so their writable children keep being swept. `/dev/shm` is shared
|
|
597
|
+
* memory that must end up READONLY (the post-condition asserts it), and a
|
|
598
|
+
* workspace under `/tmp` is refused outright. Only the two pseudo-filesystems
|
|
599
|
+
* whose children are kernel state by construction get the subtree rule, and the
|
|
600
|
+
* workspace is never one of them.
|
|
601
|
+
*/
|
|
602
|
+
export const KERNEL_SUBTREES = ['/proc', '/sys']
|
|
603
|
+
|
|
558
604
|
/**
|
|
559
605
|
* Escape ERE metacharacters so a path can only ever match itself.
|
|
560
606
|
* @param {string} text - a mount target or exempt path.
|
|
@@ -567,11 +613,19 @@ function escapeEre(text) {
|
|
|
567
613
|
/**
|
|
568
614
|
* The assembled grep -E pattern for one sweep/postcondition: regex-escaped
|
|
569
615
|
* entries joined with alternation, anchored both ends.
|
|
616
|
+
*
|
|
617
|
+
* The subtree entries are emitted the way the helper builds them — the escaped
|
|
618
|
+
* path followed by `/.*` — inside the same anchored group, so `/proc` exempts
|
|
619
|
+
* `/proc/sys/fs/binfmt_misc` while the trailing `$` still stops a sibling like
|
|
620
|
+
* `/procxyz` from matching. See {@link KERNEL_SUBTREES} for why the two fences
|
|
621
|
+
* must agree on this.
|
|
570
622
|
* @param {string[]} keep - mount targets that stay writable.
|
|
571
623
|
* @returns {string} the pattern, as data (the caller quotes it for the shell).
|
|
572
624
|
*/
|
|
573
625
|
function exemptPattern(keep) {
|
|
574
|
-
|
|
626
|
+
const exact = [...new Set([...keep, ...KERNEL_SURFACES])].map(escapeEre)
|
|
627
|
+
const subtrees = KERNEL_SUBTREES.map((kernel) => `${escapeEre(kernel)}/.*`)
|
|
628
|
+
return `^(${[...exact, ...subtrees].join('|')})$`
|
|
575
629
|
}
|
|
576
630
|
|
|
577
631
|
/**
|
|
@@ -594,6 +648,12 @@ function sweepHead(pattern) {
|
|
|
594
648
|
* Enumerated at run time from `findmnt` rather than from a fixed list, because a
|
|
595
649
|
* list is only ever as complete as the machine it was written on: a new drvfs
|
|
596
650
|
* drive or a runtime tmpfs would reopen the hole silently.
|
|
651
|
+
*
|
|
652
|
+
* A remount that fails is swallowed here and caught a step later by
|
|
653
|
+
* {@link postConditions}, which is deliberate: one unmountable target must not
|
|
654
|
+
* abort the sweep for the others, and a nested kernel mount legitimately cannot
|
|
655
|
+
* be remounted (measured: `remount,ro,bind /proc/sys/fs/binfmt_misc` answers
|
|
656
|
+
* exit 32) — those are exempted as subtrees instead, in both fences.
|
|
597
657
|
* @param {string[]} keep - mount targets that stay writable.
|
|
598
658
|
* @returns {string} the fragment.
|
|
599
659
|
*/
|
|
@@ -626,6 +686,36 @@ function readOnlySweep(keep) {
|
|
|
626
686
|
* not abort the rest, which means the only trustworthy statement about the
|
|
627
687
|
* boundary is one read back from inside it. A target that is still writable
|
|
628
688
|
* outside the allow-list is a setup failure, not a command failure.
|
|
689
|
+
*
|
|
690
|
+
* The writability verdict pairs two tests, and each is load-bearing where the
|
|
691
|
+
* other is blind.
|
|
692
|
+
*
|
|
693
|
+
* **Storage inodes are judged by `[ -w ]`.** `access(W_OK)` answers about the
|
|
694
|
+
* mount a write would actually land on — including a STACKED target (findmnt
|
|
695
|
+
* lists one record per mount), where it lands on the topmost, visible layer.
|
|
696
|
+
* `remount,ro,bind` by path reaches only that top layer, so a covered record's
|
|
697
|
+
* `rw` flag is not a hole: measured on a running WSL2 distribution, after
|
|
698
|
+
* remounting the visible `/run/user` the covered record still reported
|
|
699
|
+
* `rw,relatime` while a write through the path failed `EROFS`. Judging by that
|
|
700
|
+
* covered flag was a false positive that refused every confined command.
|
|
701
|
+
*
|
|
702
|
+
* **Everything else is judged by the MOUNT FLAG.** On a socket, FIFO or device
|
|
703
|
+
* node `[ -w ]` says nothing about the mount: the kernel's read-only answer is
|
|
704
|
+
* deliberately narrowed to storage. `sb_permission()` returns `-EROFS` only for
|
|
705
|
+
* a regular file, directory or symlink (`fs/namei.c`: `S_ISREG || S_ISDIR ||
|
|
706
|
+
* S_ISLNK`), `do_faccessat()`'s own read-only re-check — the one `[ -w ]`
|
|
707
|
+
* reaches — repeats the exclusion with `special_file(inode->i_mode)`, and
|
|
708
|
+
* between them `generic_permission()` lets the check's uid (root, because this
|
|
709
|
+
* fragment runs before the setpriv drop) through `CAP_DAC_OVERRIDE` for a
|
|
710
|
+
* `MAY_WRITE`-only mask. A SOCKET therefore reports "writable" on a mount that
|
|
711
|
+
* was successfully remounted read-only — measured as fence exit 97 for a
|
|
712
|
+
* bind-mounted unix socket under `/mnt/wsl/podman-sockets` while `podman
|
|
713
|
+
* machine` was running, refusing every confined command including a bare `true`
|
|
714
|
+
* (github issue #2).
|
|
715
|
+
*
|
|
716
|
+
* Both read ONE `findmnt -rno TARGET,OPTIONS` enumeration, and `ro` is a comma
|
|
717
|
+
* member rather than a `^ro` prefix: a mount's VFS options are a comma list and
|
|
718
|
+
* nothing pins `ro` to a position.
|
|
629
719
|
* @param {string[]} keep - mount targets that may stay writable.
|
|
630
720
|
* @returns {string} the fragment.
|
|
631
721
|
*/
|
|
@@ -634,17 +724,62 @@ function postConditions(keep) {
|
|
|
634
724
|
return [
|
|
635
725
|
'mountpoint -q /tmp || fail "/tmp is not a private tmpfs"',
|
|
636
726
|
'findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"',
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
727
|
+
// TARGET,OPTIONS in ONE enumeration, and the exemption test applies to the
|
|
728
|
+
// DECODED TARGET alone. `findmnt -r` escapes a space in a target as \x20, so
|
|
729
|
+
// the escaped target is a single space-free field and the options are the
|
|
730
|
+
// rest of the line; the split therefore happens BEFORE the decode, which is
|
|
731
|
+
// what keeps a spaced target one target (the sweep's own regression) while
|
|
732
|
+
// the options stay intact for the flag test. Test the target, not the whole
|
|
733
|
+
// line: the pattern is anchored, so a line carrying options would never
|
|
734
|
+
// match and every target — the workspace included — would be required to be
|
|
735
|
+
// read-only.
|
|
736
|
+
"findmnt -rno TARGET,OPTIONS | while IFS=' ' read -r raw opts; do",
|
|
737
|
+
// `\${raw}` is ESCAPED: this fragment is a JS template literal, and an
|
|
738
|
+
// unescaped `${raw}` is JS interpolation (a ReferenceError at build time),
|
|
739
|
+
// not the shell variable the fence needs.
|
|
740
|
+
' target=$(printf \'%b\' "${raw}");',
|
|
741
|
+
// A failing grep is not a match: the sweep has already failed closed on a
|
|
742
|
+
// broken pattern (fail "exemption grep failed"), so an unreadable exemption
|
|
743
|
+
// here can only over-ask, never under-ask. `if ...; then continue; fi` and
|
|
744
|
+
// NOT `grep ... && continue`: the failing branch of an if returns 0, while
|
|
745
|
+
// a bare `&&` list would return 1 for every non-exempt target and `set -e`
|
|
746
|
+
// would abort the fence with status 1 instead of 97.
|
|
747
|
+
` if printf '%s\\n' "$target" | grep -Eq ${shellQuote(pattern)}; then continue; fi;`,
|
|
748
|
+
// WHY THE FLAG TEST IS GATED ON REACHABILITY, and the measurement behind it.
|
|
749
|
+
// `findmnt` lists ONE RECORD PER MOUNT, and a target can be stacked: measured
|
|
750
|
+
// on a running WSL2 distribution, `/run/user` appears TWICE (a mode=755 layer
|
|
751
|
+
// and a relatime layer on top of it) and `/proc/sys/fs/binfmt_misc/status`
|
|
752
|
+
// is a second record under a first. `mount -o remount,ro,bind "$target"` by
|
|
753
|
+
// path can only ever reach the TOPMOST mount, so the covered record keeps
|
|
754
|
+
// `rw` no matter what the sweep does — measured: after remounting the visible
|
|
755
|
+
// `/run/user`, the other record still reported `rw,relatime` while a write
|
|
756
|
+
// through the path failed EROFS, i.e. the covered record is NOT a hole (it is
|
|
757
|
+
// unreachable storage). Failing on its flag was therefore a false positive
|
|
758
|
+
// that refused every confined command, which is precisely the defect class
|
|
759
|
+
// this whole change exists to remove.
|
|
760
|
+
//
|
|
761
|
+
// `[ -w ]` on the target answers about the mount a WRITE WOULD ACTUALLY
|
|
762
|
+
// LAND ON — the topmost, reachable one — which is the question the boundary
|
|
763
|
+
// is about. So the two tests are paired rather than redundant: `[ -w ]` is
|
|
764
|
+
// the reachability and writability fact, and the flag test is applied only
|
|
765
|
+
// where the flag is the authority, i.e. on a target that is not storage. For
|
|
766
|
+
// a socket/FIFO/device, `[ -w ]` says nothing about the mount (see
|
|
767
|
+
// {@link KERNEL_SURFACES} and the module note), so the flag is what decides.
|
|
768
|
+
' if [ -d "$target" ] || [ -f "$target" ]; then',
|
|
769
|
+
// Storage inode: `[ -w ]` decides. It already covers a covered record (the
|
|
770
|
+
// write would land on the reachable mount) and needs no flag test.
|
|
771
|
+
' if [ -w "$target" ]; then fail "$target is still writable"; fi;',
|
|
772
|
+
' else',
|
|
773
|
+
// Not storage: the mount flag decides, and it must be `ro` as a comma member
|
|
774
|
+
// (a mount's VFS options are a comma list; `ro` is not pinned to a position).
|
|
775
|
+
' case ",$opts," in *,ro,*) ;; *) fail "$target is not read-only (mount options: $opts)" ;; esac;',
|
|
776
|
+
' fi;',
|
|
777
|
+
// The trailing `true` keeps the pipeline itself green: this while is the
|
|
778
|
+
// LAST element of a pipeline, and anything that ends the body with a
|
|
779
|
+
// non-zero status would fail the whole pipeline under `set -o pipefail` and
|
|
780
|
+
// abort the script before the command runs. A real `fail` still exits 97
|
|
781
|
+
// from inside the loop, which pipefail turns into the script's own setup
|
|
782
|
+
// failure — the marker and exit code still surface.
|
|
648
783
|
' true;',
|
|
649
784
|
'done',
|
|
650
785
|
'true',
|
|
@@ -776,6 +911,22 @@ export function buildConfinedCommand({ command, linuxCwd, mode, workspaceLinuxRo
|
|
|
776
911
|
}
|
|
777
912
|
const script = buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs })
|
|
778
913
|
const flags = ['--mount', '--propagation', 'private']
|
|
779
|
-
|
|
780
|
-
|
|
914
|
+
// A PID namespace re-parents the tree but the inherited procfs still lists the PARENT's
|
|
915
|
+
// pids, so /proc/<outer-pid>/root names the outer mount namespace — where nothing was
|
|
916
|
+
// remounted read-only. `--mount-proc` is what closes that, and it is one control with
|
|
917
|
+
// the pid namespace, not an option beside it.
|
|
918
|
+
if (isolateProcesses) flags.push('--pid', '--fork', '--mount-proc')
|
|
919
|
+
// The helper pins PATH and runs its fence under `bash -p` (see its own comments for the
|
|
920
|
+
// measurements). This runner emits the same fence body, so it must carry the same
|
|
921
|
+
// invariants: without the pin, a caller able to pass a sudo command-line assignment
|
|
922
|
+
// shadows mount/findmnt/mountpoint and the postconditions pass while the mounts stay
|
|
923
|
+
// writable — a fence that lies. `-p` skips BASH_ENV and the function import for the
|
|
924
|
+
// fence body, and the three `-u` removals close the interop door (a Windows program
|
|
925
|
+
// launched from here can start an unfenced `wsl.exe ... bash`, where NO_NEW_PRIVS does
|
|
926
|
+
// not apply and the retained sudo grant re-escalates normally).
|
|
927
|
+
const hardened = [
|
|
928
|
+
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH;',
|
|
929
|
+
script,
|
|
930
|
+
].join('\n')
|
|
931
|
+
return `sudo -n unshare ${flags.join(' ')} env -u BASH_ENV -u WSL_INTEROP -u WSLENV -u WSL_DISTRO_NAME bash -p -c ${shellQuote(hardened)}`
|
|
781
932
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Privileged mode (-p): this script runs as root via sudo, and without -p bash would
|
|
3
3
|
# source a caller-supplied BASH_ENV file BEFORE our first line, as root. The
|
|
4
4
|
# startup-defence note below carries the measurement (bash 5.2.37).
|
|
5
|
-
# dsh-wsl-confine v1.
|
|
5
|
+
# dsh-wsl-confine v1.3 — DSH WSL confinement helper.
|
|
6
6
|
#
|
|
7
7
|
# Root-owned fence executor: the ONLY thing this helper does is apply the
|
|
8
8
|
# mount-namespace fence (workspace bind, tmpfs /tmp, read-only /, read-only
|
|
@@ -88,7 +88,7 @@ export PATH
|
|
|
88
88
|
# purpose: it must not depend on the deployment's sudoers either way.
|
|
89
89
|
|
|
90
90
|
set -euo pipefail
|
|
91
|
-
VERSION='dsh-wsl-confine v1.
|
|
91
|
+
VERSION='dsh-wsl-confine v1.3'
|
|
92
92
|
|
|
93
93
|
uid=; gid=; home=; cwd=; workspace=; pidns=1
|
|
94
94
|
ARGS=()
|
|
@@ -194,8 +194,24 @@ command -v findmnt >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: fin
|
|
|
194
194
|
# form is not portable and the refusal above is what the fence relies on.)
|
|
195
195
|
KEEP_ENTRIES=(/tmp /dev /dev/pts /dev/mqueue /proc /sys)
|
|
196
196
|
[[ -z "$workspace" ]] || KEEP_ENTRIES+=("$workspace")
|
|
197
|
+
|
|
198
|
+
# Kernel virtual filesystems are exempt as SUBTREES, not as single mount points. A fresh
|
|
199
|
+
# `--mount-proc` brings /proc's own nested mounts with it (measured: /proc/sys/fs/binfmt_misc
|
|
200
|
+
# is a separate mount record), and a nested kernel mount cannot be made read-only — it is
|
|
201
|
+
# not backing storage. Exact-entry matching left those children unexempt, so the
|
|
202
|
+
# postcondition found the first one writable and the fence refused every command.
|
|
203
|
+
#
|
|
204
|
+
# The subtree list is deliberately NARROWER than KEEP_ENTRIES. /dev and /tmp stay exact so
|
|
205
|
+
# their writable children keep being swept: /dev/shm is shared memory that must end up
|
|
206
|
+
# READONLY (the post-condition asserts it), and a workspace under /tmp is already refused
|
|
207
|
+
# outright. The subtree rule applies only to the two pseudo-filesystems whose children are
|
|
208
|
+
# kernel state by construction, and the workspace is never one of them.
|
|
209
|
+
KERNEL_SUBTREE=(/proc /sys)
|
|
197
210
|
EXEMPT_PATTERN=$(printf '%s\n' "${KEEP_ENTRIES[@]}" | sed 's/[][\\^$.*+?(){}|]/\\&/g' | tr '\n' '|')
|
|
198
211
|
EXEMPT_PATTERN="${EXEMPT_PATTERN%|}"
|
|
212
|
+
for kernel in "${KERNEL_SUBTREE[@]}"; do
|
|
213
|
+
EXEMPT_PATTERN="${EXEMPT_PATTERN}|$(printf '%s' "$kernel" | sed 's/[][\\^$.*+?(){}|]/\\&/g')/.*"
|
|
214
|
+
done
|
|
199
215
|
|
|
200
216
|
# The fence script is built HERE from the validated parameters — the caller
|
|
201
217
|
# never supplies script text. It is identical in effect to the in-process
|
|
@@ -218,8 +234,30 @@ findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | {
|
|
|
218
234
|
done
|
|
219
235
|
mountpoint -q /tmp || fail "/tmp is not a private tmpfs"
|
|
220
236
|
findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"
|
|
221
|
-
|
|
222
|
-
|
|
237
|
+
# The postcondition pairs two tests, and each is load-bearing where the other is
|
|
238
|
+
# blind. A target can be STACKED — findmnt lists one record per mount — and
|
|
239
|
+
# `remount,ro,bind` by path only ever reaches the TOPMOST one (measured: after
|
|
240
|
+
# remounting /run/user, a covered record still reported rw while a write through
|
|
241
|
+
# the path failed EROFS — unreachable storage, not a hole). So:
|
|
242
|
+
# * a REGULAR FILE or DIRECTORY is judged by `[ -w ]`: it answers about the
|
|
243
|
+
# mount a write would actually land on, covered record or not;
|
|
244
|
+
# * anything else (socket, FIFO, device) is judged by the MOUNT FLAG, because
|
|
245
|
+
# `[ -w ]` is meaningless there: access(W_OK) narrows the kernel's read-only
|
|
246
|
+
# answer to S_ISREG/S_ISDIR/S_ISLNK (sb_permission), do_faccessat() adds a
|
|
247
|
+
# special_file() exclusion, and this fragment runs as root (CAP_DAC_OVERRIDE)
|
|
248
|
+
# before the setpriv drop. A socket therefore reported "writable" on a mount
|
|
249
|
+
# that WAS remounted read-only: measured as exit 97 for the unix socket under
|
|
250
|
+
# /mnt/wsl/podman-sockets with podman machine running, refusing every
|
|
251
|
+
# confined command including a bare `true` (github issue #2).
|
|
252
|
+
findmnt -rno TARGET,OPTIONS | while IFS=" " read -r raw opts; do
|
|
253
|
+
target=$(printf "%b" "$raw")
|
|
254
|
+
if printf "%s\n" "$target" | grep -Eq "^(${DROP_EXEMPT})\$"; then continue; fi
|
|
255
|
+
if [[ -d "$target" || -f "$target" ]]; then
|
|
256
|
+
if [[ -w "$target" ]]; then fail "$target is still writable"; fi
|
|
257
|
+
else
|
|
258
|
+
# Comma-delimited membership, not a ^ro prefix: ro is not pinned to a position.
|
|
259
|
+
case ",$opts," in *,ro,*) ;; *) fail "$target is not read-only (mount options: $opts)" ;; esac
|
|
260
|
+
fi
|
|
223
261
|
true
|
|
224
262
|
done
|
|
225
263
|
cd "$DROP_CWD"
|
|
@@ -228,12 +266,28 @@ exec setpriv --no-new-privs --reuid="$DROP_UID" --regid="$DROP_GID" --init-group
|
|
|
228
266
|
PID_FLAG=''
|
|
229
267
|
[[ "$pidns" = 1 ]] && PID_FLAG='--pid --fork'
|
|
230
268
|
|
|
269
|
+
# A PID namespace alone does NOT hide the outer processes: it re-parents this tree, but
|
|
270
|
+
# the procfs it inherits still lists the PARENT's pids, so /proc/<outer-pid>/root keeps
|
|
271
|
+
# naming the outer mount namespace — where / and /mnt/c were never remounted read-only,
|
|
272
|
+
# because that remount is private to this fence. `--mount-proc` mounts a FRESH procfs over
|
|
273
|
+
# /proc, so a magic link can only ever name a process inside this namespace. The two
|
|
274
|
+
# halves are one control, which is why the flag is tied to the pid namespace and not
|
|
275
|
+
# offered separately: a fresh procfs without a pid namespace would still list outer pids.
|
|
276
|
+
PROC_FLAG=''
|
|
277
|
+
[[ "$pidns" = 1 ]] && PROC_FLAG='--mount-proc'
|
|
278
|
+
|
|
279
|
+
# WSL interop is a door OUT of the fence, and NO_NEW_PRIVS does not survive it: a confined
|
|
280
|
+
# command can launch a WINDOWS program, and that program can start a fresh
|
|
281
|
+
# `wsl.exe -d <distro> -e bash` — a new Linux process in the INITIAL mount namespace
|
|
282
|
+
# carrying no NNP, free to use the passwordless sudo grant this runner itself requires.
|
|
283
|
+
# The door is opened by environment variables, so they are removed on the one invocation
|
|
284
|
+
# every root-phase descendant hangs off. /mnt/c stays mounted read-only under the fence
|
|
285
|
+
# and interop was the way around that write fence, not a way through it.
|
|
286
|
+
#
|
|
231
287
|
# -p stops OUR bash from processing BASH_ENV, but it does NOT remove the variable from
|
|
232
288
|
# the environment, and every descendant inherits it. The drop-side `bash -lc
|
|
233
289
|
# "$DROP_COMMAND"` is NOT privileged and does process it (measured: it sourced the
|
|
234
|
-
# caller's file as the session user, inside the fence). Drop it HERE
|
|
235
|
-
# invocation every root-phase descendant hangs off, so no descendant can be made to
|
|
236
|
-
# source a caller's file.
|
|
290
|
+
# caller's file as the session user, inside the fence). Drop it HERE.
|
|
237
291
|
#
|
|
238
292
|
# -p on THIS file's bash also stops the import of functions from the environment
|
|
239
293
|
# into THIS shell only. The fence body is a separate `bash -c`, so without -p there a
|
|
@@ -243,7 +297,8 @@ PID_FLAG=''
|
|
|
243
297
|
# therefore launched with -p as well: the same privileged-mode rule that skips
|
|
244
298
|
# BASH_ENV skips the function import, so a coordinated override becomes a no-op
|
|
245
299
|
# instead of a fence that lies.
|
|
246
|
-
exec unshare --mount --propagation private $PID_FLAG
|
|
300
|
+
exec unshare --mount --propagation private $PID_FLAG $PROC_FLAG \
|
|
301
|
+
env -u BASH_ENV -u WSL_INTEROP -u WSLENV -u WSL_DISTRO_NAME \
|
|
247
302
|
DROP_UID="$uid" DROP_GID="$gid" DROP_HOME="$home" DROP_USER="${SUDO_USER:-root}" \
|
|
248
303
|
DROP_CWD="$cwd" DROP_WORKSPACE="$workspace" DROP_COMMAND="${ARGS[*]}" \
|
|
249
304
|
DROP_EXEMPT="$EXEMPT_PATTERN" \
|
package/lib/wsl/fs.js
CHANGED
|
@@ -162,7 +162,39 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
162
162
|
// the share refuses that one step transiently (see ./publish.js for the
|
|
163
163
|
// measurement). The publication is waited out rather than reported as a
|
|
164
164
|
// failure the caller cannot tell apart from a real one.
|
|
165
|
+
//
|
|
166
|
+
// The containment re-test lives HERE, next to the syscall, because that is the only
|
|
167
|
+
// place it can see a component swapped after `checkedTarget`: the fence's own
|
|
168
|
+
// documentation records that a rename through such a component lands the file AT THE
|
|
169
|
+
// LINK'S TARGET, so the window is a content escape, not just an out-of-bounds
|
|
170
|
+
// directory. Refusing here reports the same structured denial the entry check uses.
|
|
165
171
|
replaceFile: async (replaced, replacement) => {
|
|
172
|
+
// Resolve the policy HERE rather than carrying a snapshot. The primitive's signature
|
|
173
|
+
// is `(replaced: string, replacement: string)`, so no per-call context can reach it,
|
|
174
|
+
// and an instance field would be shared by every concurrent write — task B's roots
|
|
175
|
+
// would judge task A's publication. Resolving at the syscall is also the sounder
|
|
176
|
+
// semantic: the re-test answers "is this path inside a writable root RIGHT NOW",
|
|
177
|
+
// and a policy that changed since the entry check should narrow the write, not be
|
|
178
|
+
// ignored in favour of a stale grant.
|
|
179
|
+
const policy = this.ctx.sandboxPolicy.resolve()
|
|
180
|
+
if (policy.mode !== 'danger-full-access') {
|
|
181
|
+
// `replaced` is a HOST spelling (a UNC or a drive path), so its parent is a win32
|
|
182
|
+
// concern, not a POSIX one.
|
|
183
|
+
const parent = await this.resolve(win32.dirname(replaced))
|
|
184
|
+
let contained = false
|
|
185
|
+
for (const root of this.writableHostRoots(policy)) {
|
|
186
|
+
if (await isUnderHost(parent.targetKey, root)) {
|
|
187
|
+
contained = true
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!contained) {
|
|
192
|
+
throw new FsError(
|
|
193
|
+
`cannot write "${replaced}": the destination left the writable roots between the check and the publication`,
|
|
194
|
+
'FS_SANDBOX_DENIED',
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
166
198
|
await publishReplace(replaced, replacement)
|
|
167
199
|
},
|
|
168
200
|
// The share exposes no settable DACL, and staging is already private.
|
package/lib/wsl/sandbox.js
CHANGED
|
@@ -51,10 +51,17 @@ export const Config = z.object({
|
|
|
51
51
|
/**
|
|
52
52
|
* Add a PID namespace to the fence. OFF for this provider's consumers on purpose: the
|
|
53
53
|
* argv it wraps is an INTERACTIVE shell on a PTY, and a PID namespace re-parents that
|
|
54
|
-
* shell to PID 1
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* namespace
|
|
54
|
+
* shell to PID 1, which the PTY path cannot live with. Re-measured while closing the
|
|
55
|
+
* /proc escape: `tcgetpgrp` alone DOES still resolve under `--pid --fork --mount-proc`
|
|
56
|
+
* (TPGID names the shell's own pid), but driving the real `terminal-bridge.py` through
|
|
57
|
+
* the same namespace HANGS — it never completes its start, so the terminal is dead.
|
|
58
|
+
* The one-shot shell executor keeps the namespace (its own default); this is the
|
|
59
|
+
* terminal path only.
|
|
60
|
+
*
|
|
61
|
+
* The /proc consequence is accepted HERE and closed on the one-shot path: with the
|
|
62
|
+
* namespace off, a fresh procfs would still list the outer pids, so this arm carries no
|
|
63
|
+
* `--mount-proc` either — mounting one would buy nothing. What the terminal path cannot
|
|
64
|
+
* have, `shell.js` and the direct runner do have.
|
|
58
65
|
*/
|
|
59
66
|
isolateProcesses: z.boolean().default(false),
|
|
60
67
|
})
|