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 +49 -21
- package/lib/wsl/confinement.js +18 -2
- package/lib/wsl/dsh-wsl-confine.sh +37 -4
- package/lib/wsl/fs.js +32 -0
- package/lib/wsl/sandbox.js +11 -4
- package/package.json +1 -1
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/wsl/confinement.js
CHANGED
|
@@ -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
|
-
|
|
780
|
-
|
|
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
|
|
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
|
|
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.
|
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
|
})
|