dsh-wsl-desktop 0.7.4 → 0.7.6

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 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.2': '未安装或版本不是 v1.2',
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.2).
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/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.2;安装说明见 docs/CONFINEMENT.md。',
206
- en: 'The helper is missing or not v1.2; see docs/CONFINEMENT.md for the install command.',
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',
@@ -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.2'
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
- return `^(${[...new Set([...keep, ...KERNEL_SURFACES])].map(escapeEre).join('|')})$`
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,75 @@ 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
- // Same line-driven loop, decode-before-match, and single-quoted pattern
638
- // as the sweep (fail() is defined in the setup steps). The trailing
639
- // `true` matters: the while is the LAST element of a pipeline, so a body
640
- // that ends with a failing `[ -w ]` (the normal case — every swept target
641
- // IS read-only) would otherwise fail the whole pipeline under
642
- // `set -o pipefail` and abort the script before the command runs. A real
643
- // `fail` still exits 97 from inside the loop, which pipefail turns into
644
- // the script's own setup failure — the marker and exit code still
645
- // surface.
646
- sweepHead(pattern),
647
- ' [ -w "$target" ] && fail "$target is still writable";',
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
+ //
769
+ // WHICH test applies is decided by the inode TYPE from `stat -c %F`, NOT by
770
+ // `-f`/`-d`. Measured: after the fence binds the workspace onto itself, a
771
+ // unix socket bind-mounted UNDER the workspace answers `-f` TRUE, while the
772
+ // same socket answers `-f` FALSE before that bind — the shell test follows
773
+ // the mount the path resolves to, and the workspace bind changes what that
774
+ // is. Gating on `-f`/`-d` therefore sent the socket down the storage branch,
775
+ // where it failed with "is still writable": the issue #2 symptom this change
776
+ // exists to remove, reproduced by the fix itself. `stat` reports the inode
777
+ // class directly and is not fooled by the bind.
778
+ ' kind=$(stat -c %F "$target" 2>/dev/null) || kind="";',
779
+ ' case "$kind" in',
780
+ " 'regular file'|'regular empty file'|directory|'symbolic link')",
781
+ // Storage inode: `[ -w ]` decides. It already covers a covered record (the
782
+ // write would land on the reachable mount) and needs no flag test.
783
+ ' if [ -w "$target" ]; then fail "$target is still writable"; fi;;',
784
+ ' *)',
785
+ // Not storage, or unreadable: the mount flag decides, and it must be `ro` as
786
+ // a comma member (a mount's VFS options are a comma list; `ro` has no fixed
787
+ // position).
788
+ ' case ",$opts," in *,ro,*) ;; *) fail "$target is not read-only (mount options: $opts)" ;; esac;;',
789
+ ' esac;',
790
+ // The trailing `true` keeps the pipeline itself green: this while is the
791
+ // LAST element of a pipeline, and anything that ends the body with a
792
+ // non-zero status would fail the whole pipeline under `set -o pipefail` and
793
+ // abort the script before the command runs. A real `fail` still exits 97
794
+ // from inside the loop, which pipefail turns into the script's own setup
795
+ // failure — the marker and exit code still surface.
648
796
  ' true;',
649
797
  'done',
650
798
  'true',
@@ -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.2 — DSH WSL confinement helper.
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
@@ -36,8 +36,8 @@
36
36
  # fence to the forged uid: root WRITE on the distribution's filesystem and on
37
37
  # /mnt/c, with only the namespace and NO_NEW_PRIVS left standing.
38
38
  #
39
- # This script runs as root and calls twelve tools by bare name (getent, cut, sed,
40
- # tr, mount, findmnt, grep, mountpoint, setpriv, env, bash, unshare). sudo's
39
+ # This script runs as root and calls thirteen tools by bare name (getent, cut, sed,
40
+ # tr, mount, findmnt, grep, mountpoint, setpriv, env, bash, unshare, stat). sudo's
41
41
  # env_reset does not save it: an exported PATH is replaced when sudoers sets
42
42
  # secure_path, but a PATH handed over as a sudo command-line assignment still
43
43
  # reaches us (measured on debian, debian-dev and arch), and the NOPASSWD grant is
@@ -47,7 +47,7 @@
47
47
  # here, as the FIRST statement, rather than depending on a deployment option we
48
48
  # cannot verify.
49
49
  #
50
- # The pin is unconditional and has NO fallback: the twelve tools must be reachable
50
+ # The pin is unconditional and has NO fallback: the thirteen tools must be reachable
51
51
  # in these six directories. A distribution where one is not fails CLOSED - nothing
52
52
  # runs: a missing getent leaves the caller record empty and the gate refuses it, a
53
53
  # missing cut fails the pipeline under pipefail (127) before the fence, and the
@@ -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.2'
91
+ VERSION='dsh-wsl-confine v1.3'
92
92
 
93
93
  uid=; gid=; home=; cwd=; workspace=; pidns=1
94
94
  ARGS=()
@@ -170,6 +170,11 @@ fi
170
170
  command -v unshare >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: unshare not found' >&2; exit 97; }
171
171
  command -v setpriv >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: setpriv not found' >&2; exit 97; }
172
172
  command -v findmnt >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: findmnt not found' >&2; exit 97; }
173
+ # `stat` classifies the post-condition's target and is called INSIDE the fence,
174
+ # where a bare-name lookup failure would leave kind empty — that routes to the
175
+ # flag branch, which is fail-closed, but the report would then be a confusing
176
+ # "not read-only" rather than the real cause. Preflight it like the others.
177
+ command -v stat >/dev/null 2>&1 || { echo 'dsh-wsl-sandbox: setup failed: stat not found' >&2; exit 97; }
173
178
 
174
179
  # The allow-list pattern is built HERE, from the validated parameters, with every
175
180
  # entry escaped so a PATH stays data and never becomes regex syntax. Unescaped,
@@ -234,8 +239,52 @@ findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | {
234
239
  done
235
240
  mountpoint -q /tmp || fail "/tmp is not a private tmpfs"
236
241
  findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"
237
- findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | { grep -Ev "^(${DROP_EXEMPT})\$" || fail "exemption grep failed"; } | while IFS= read -r target; do
238
- [[ -w "$target" ]] && fail "$target is still writable"
242
+ # NOTE: this block lives inside the single-quoted FENCE string. A BACKTICK here
243
+ # would close the quoting and let bash run its contents as a command
244
+ # substitution — measured: eight backticks around the test -w mention made the
245
+ # helper exit 127 with "read-only: command not found" before the fence ran at
246
+ # all. Keep this comment free of backticks and of single quotes.
247
+ # The postcondition pairs two tests, and each is load-bearing where the other is
248
+ # blind. A target can be STACKED — findmnt lists one record per mount — and
249
+ # remount,ro,bind by path only ever reaches the TOPMOST one (measured: after
250
+ # remounting /run/user, a covered record still reported rw while a write through
251
+ # the path failed EROFS — unreachable storage, not a hole). So:
252
+ # * a REGULAR FILE or DIRECTORY is judged by test -w: it answers about the
253
+ # mount a write would actually land on, covered record or not;
254
+ # * anything else (socket, FIFO, device) is judged by the MOUNT FLAG, because
255
+ # test -w is meaningless there: access(W_OK) narrows the kernel read-only
256
+ # answer to S_ISREG/S_ISDIR/S_ISLNK (sb_permission), do_faccessat() adds a
257
+ # special_file() exclusion, and this fragment runs as root (CAP_DAC_OVERRIDE)
258
+ # before the setpriv drop. A socket therefore reported "writable" on a mount
259
+ # that WAS remounted read-only: measured as exit 97 for the unix socket under
260
+ # /mnt/wsl/podman-sockets with podman machine running, refusing every
261
+ # confined command including a bare true (github issue #2).
262
+ findmnt -rno TARGET,OPTIONS | while IFS=" " read -r raw opts; do
263
+ target=$(printf "%b" "$raw")
264
+ if printf "%s\n" "$target" | grep -Eq "^(${DROP_EXEMPT})\$"; then continue; fi
265
+ # WHICH test applies is decided by the inode TYPE, read from stat, and NOT by
266
+ # the -f/-d shell tests. Measured: after the fence binds the workspace onto
267
+ # itself, a unix socket bind-mounted UNDER the workspace answers -f TRUE, while
268
+ # the same socket answers -f FALSE before that bind — the shell test follows the
269
+ # mount the path resolves to and the workspace bind changes what that is. So
270
+ # -f/-d cannot tell "storage" from "special file" here, and using them sent the
271
+ # socket down the -w branch, where it failed with "is still writable" exactly as
272
+ # issue #2 reported. stat -c %F reports the inode class directly.
273
+ kind=$(stat -c %F "$target" 2>/dev/null) || kind=""
274
+ # NOTE: double quotes, never single: this block is inside the single-quoted
275
+ # FENCE string, so a single quote would close it. The case PATTERNS below are
276
+ # matched literally, so their quoting form is part of the comparison.
277
+ case "$kind" in
278
+ "regular file"|"regular empty file"|directory|"symbolic link")
279
+ # Storage: test -w answers about the mount a write would actually land on.
280
+ if [[ -w "$target" ]]; then fail "$target is still writable"; fi
281
+ ;;
282
+ *)
283
+ # Not storage (socket, fifo, device, or unreadable): the mount FLAG decides.
284
+ # Comma-delimited membership, not a ^ro prefix: ro is not pinned to a position.
285
+ case ",$opts," in *,ro,*) ;; *) fail "$target is not read-only (mount options: $opts)" ;; esac
286
+ ;;
287
+ esac
239
288
  true
240
289
  done
241
290
  cd "$DROP_CWD"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",