dsh-wsl-desktop 0.7.3 → 0.7.4

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/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
@@ -776,6 +776,22 @@ export function buildConfinedCommand({ command, linuxCwd, mode, workspaceLinuxRo
776
776
  }
777
777
  const script = buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs })
778
778
  const flags = ['--mount', '--propagation', 'private']
779
- if (isolateProcesses) flags.push('--pid', '--fork')
780
- return `sudo -n unshare ${flags.join(' ')} bash -c ${shellQuote(script)}`
779
+ // A PID namespace re-parents the tree but the inherited procfs still lists the PARENT's
780
+ // pids, so /proc/<outer-pid>/root names the outer mount namespace — where nothing was
781
+ // remounted read-only. `--mount-proc` is what closes that, and it is one control with
782
+ // the pid namespace, not an option beside it.
783
+ if (isolateProcesses) flags.push('--pid', '--fork', '--mount-proc')
784
+ // The helper pins PATH and runs its fence under `bash -p` (see its own comments for the
785
+ // measurements). This runner emits the same fence body, so it must carry the same
786
+ // invariants: without the pin, a caller able to pass a sudo command-line assignment
787
+ // shadows mount/findmnt/mountpoint and the postconditions pass while the mounts stay
788
+ // writable — a fence that lies. `-p` skips BASH_ENV and the function import for the
789
+ // fence body, and the three `-u` removals close the interop door (a Windows program
790
+ // launched from here can start an unfenced `wsl.exe ... bash`, where NO_NEW_PRIVS does
791
+ // not apply and the retained sudo grant re-escalates normally).
792
+ const hardened = [
793
+ 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH;',
794
+ script,
795
+ ].join('\n')
796
+ 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
797
  }
@@ -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
@@ -228,12 +244,28 @@ exec setpriv --no-new-privs --reuid="$DROP_UID" --regid="$DROP_GID" --init-group
228
244
  PID_FLAG=''
229
245
  [[ "$pidns" = 1 ]] && PID_FLAG='--pid --fork'
230
246
 
247
+ # A PID namespace alone does NOT hide the outer processes: it re-parents this tree, but
248
+ # the procfs it inherits still lists the PARENT's pids, so /proc/<outer-pid>/root keeps
249
+ # naming the outer mount namespace — where / and /mnt/c were never remounted read-only,
250
+ # because that remount is private to this fence. `--mount-proc` mounts a FRESH procfs over
251
+ # /proc, so a magic link can only ever name a process inside this namespace. The two
252
+ # halves are one control, which is why the flag is tied to the pid namespace and not
253
+ # offered separately: a fresh procfs without a pid namespace would still list outer pids.
254
+ PROC_FLAG=''
255
+ [[ "$pidns" = 1 ]] && PROC_FLAG='--mount-proc'
256
+
257
+ # WSL interop is a door OUT of the fence, and NO_NEW_PRIVS does not survive it: a confined
258
+ # command can launch a WINDOWS program, and that program can start a fresh
259
+ # `wsl.exe -d <distro> -e bash` — a new Linux process in the INITIAL mount namespace
260
+ # carrying no NNP, free to use the passwordless sudo grant this runner itself requires.
261
+ # The door is opened by environment variables, so they are removed on the one invocation
262
+ # every root-phase descendant hangs off. /mnt/c stays mounted read-only under the fence
263
+ # and interop was the way around that write fence, not a way through it.
264
+ #
231
265
  # -p stops OUR bash from processing BASH_ENV, but it does NOT remove the variable from
232
266
  # the environment, and every descendant inherits it. The drop-side `bash -lc
233
267
  # "$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.
268
+ # caller's file as the session user, inside the fence). Drop it HERE.
237
269
  #
238
270
  # -p on THIS file's bash also stops the import of functions from the environment
239
271
  # into THIS shell only. The fence body is a separate `bash -c`, so without -p there a
@@ -243,7 +275,8 @@ PID_FLAG=''
243
275
  # therefore launched with -p as well: the same privileged-mode rule that skips
244
276
  # BASH_ENV skips the function import, so a coordinated override becomes a no-op
245
277
  # instead of a fence that lies.
246
- exec unshare --mount --propagation private $PID_FLAG env -u BASH_ENV \
278
+ exec unshare --mount --propagation private $PID_FLAG $PROC_FLAG \
279
+ env -u BASH_ENV -u WSL_INTEROP -u WSLENV -u WSL_DISTRO_NAME \
247
280
  DROP_UID="$uid" DROP_GID="$gid" DROP_HOME="$home" DROP_USER="${SUDO_USER:-root}" \
248
281
  DROP_CWD="$cwd" DROP_WORKSPACE="$workspace" DROP_COMMAND="${ARGS[*]}" \
249
282
  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.4",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",