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 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/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
- const probeCreated = await runShellSpec(shell, shell.resolve({ command: `printf 'x\\n' > '${fs.processPath(fileTarget)}'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
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' >> '${fs.processPath(fileTarget)}'`)
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 dirCommand = `printf 'x\\n' > '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`
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 '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
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 the
879
- // execution result carries `value` (often a serialized JSON string). The
880
- // rows below read `.text`, so the text is what this normalises: a string
881
- // value is used as-is, anything else is serialized.
882
- const execution = outcome.result ?? {}
883
- const isError = execution.isError === true || execution.error !== undefined
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 ?? execution ?? null)
887
- return isError
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.text ?? JSON.stringify(bashTool.result ?? null)).slice(0, 400),
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.text ?? JSON.stringify(readTool.result ?? null)).slice(0, 400),
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.text ?? JSON.stringify(pwshTool.result ?? null)).slice(0, 200),
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.text ?? JSON.stringify(grepSearch.result ?? null)).slice(0, 800),
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.text ?? JSON.stringify(globSearch.result ?? null)).slice(0, 800),
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.text ?? JSON.stringify(persistA.result ?? null)).slice(0, 400),
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.text ?? JSON.stringify(persistB.result ?? null)).slice(0, 800),
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.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,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
- // 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
+ ' 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
- if (isolateProcesses) flags.push('--pid', '--fork')
780
- return `sudo -n unshare ${flags.join(' ')} bash -c ${shellQuote(script)}`
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.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
@@ -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=()
@@ -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
- 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
222
- [[ -w "$target" ]] && fail "$target is still writable"
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, on the one
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 env -u BASH_ENV \
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.
@@ -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 with a process group the PTY's own foreground queries
55
- * (`terminal-bridge.py`'s `tcgetpgrp`) can no longer address — the reason the shipped
56
- * helper carries `--no-pidns` at all. The shell executor's one-shot commands keep the
57
- * namespace (its own default); this is the terminal path only.
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
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",