dsh-plugin-wsl-env 0.2.0 → 0.3.0

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
@@ -43,7 +43,7 @@ import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
43
43
  import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
44
44
  import { FsError } from "@deepseek-ai/dsh-fs";
45
45
  import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
46
- import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix } from "./paths.js";
46
+ import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
47
47
  import { WslSandbox } from "./sandbox.js";
48
48
  import { armDistroWatcher } from "./watcher.js";
49
49
  import { WslAgent } from "./agent.js";
@@ -51,6 +51,9 @@ import { AgentUnavailableError } from "./agent-errors.js";
51
51
  import { publicationArgv } from "./fs-publish.js";
52
52
  import { agentExecutionHandle } from "./agent-exec.js";
53
53
  import { sharedAgent } from "./agent-shared.js";
54
+ import { AgentSubstrate } from "./fs-substrate.js";
55
+ import { FsCodedError } from "./fsio-text.js";
56
+ import { confinedAgent } from "./agent-confined.js";
54
57
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
55
58
  // can use them without pulling this file's executor and filesystem peers in.
56
59
  import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
@@ -172,6 +175,20 @@ export class WslShellExecutor extends LocalBashExecutor {
172
175
  * unconfined and `sandboxMode` is `undefined`, so the model is told so.
173
176
  */
174
177
  sandbox: z.boolean().default(true),
178
+ /**
179
+ * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
180
+ * Windows drive from confined commands: its files cannot be read or
181
+ * exfiltrated and its executables cannot be launched through interop —
182
+ * the hole that lets a "sandboxed" command reach your full Windows token.
183
+ * A narrowing, not a closure (a command can still write an executable
184
+ * into the workspace and run it — binfmt dispatches on content, not
185
+ * location), so enforcement stays `partial` and the sandbox fact reports
186
+ * `windowsDrive: "masked"`. Costs: confined commands cannot read or touch
187
+ * anything under `/mnt`, and tools that probe the drive degrade. The
188
+ * complete closure is distro-level: `[interop] enabled=false` in
189
+ * `wsl.conf`.
190
+ */
191
+ maskWindowsDrive: z.boolean().default(false),
175
192
  /**
176
193
  * Default workdir for a request that names none; accepts `/home/andy/proj`,
177
194
  * a UNC path, or `C:\...`. Empty — the default — means the distro user's
@@ -447,6 +464,7 @@ export class WslShellExecutor extends LocalBashExecutor {
447
464
  mode: facts.mode,
448
465
  denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
449
466
  enforcement: facts.enforcement,
467
+ ...(facts.windowsDrive ? { windowsDrive: facts.windowsDrive } : {}),
450
468
  ...(runnerFailed ? { runnerFailed } : {}),
451
469
  };
452
470
  }
@@ -552,6 +570,10 @@ export class WslShellExecutor extends LocalBashExecutor {
552
570
  cwd: toLinuxPath(resolved.workdir, { distro }),
553
571
  argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
554
572
  timeoutMs: resolved.timeoutMs,
573
+ // The agent enforces the collect budget distro-side (the EXEC frame
574
+ // carries it); `maxSpillBytes` stays one-shot-only — the agent path has
575
+ // no spill files by design, its capture is bounded instead.
576
+ maxOutputBytes: this.config.maxOutputBytes,
555
577
  signal: resolved.signal,
556
578
  onStarted: confined !== undefined
557
579
  ? (proc) => {
@@ -562,6 +584,7 @@ export class WslShellExecutor extends LocalBashExecutor {
562
584
  runnerFailureRules: confined.runnerFailureRules,
563
585
  runnerProgram: confined.argv[0],
564
586
  workdir: resolved.workdir,
587
+ windowsDrive: confined.windowsDrive,
565
588
  });
566
589
  }
567
590
  : undefined,
@@ -571,7 +594,7 @@ export class WslShellExecutor extends LocalBashExecutor {
571
594
  this.throwIfWorkdirMissing(result, resolved.workdir);
572
595
  if (this.sandbox === undefined) return result;
573
596
  if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
574
- const { enforcement, denialSignatures, runnerFailureRules } = confined;
597
+ const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
575
598
  const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
576
599
  if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
577
600
  return {
@@ -580,6 +603,7 @@ export class WslShellExecutor extends LocalBashExecutor {
580
603
  mode,
581
604
  denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
582
605
  enforcement,
606
+ windowsDrive,
583
607
  },
584
608
  };
585
609
  }, (error) => {
@@ -635,6 +659,7 @@ export class WslShellExecutor extends LocalBashExecutor {
635
659
  runnerFailureRules: confined.runnerFailureRules,
636
660
  runnerProgram: confined.argv[0],
637
661
  workdir: resolved.workdir,
662
+ windowsDrive: confined.windowsDrive,
638
663
  });
639
664
  },
640
665
  );
@@ -642,7 +667,7 @@ export class WslShellExecutor extends LocalBashExecutor {
642
667
  this.throwIfDistroUnavailable(result, distro);
643
668
  this.throwIfWorkdirMissing(result, resolved.workdir);
644
669
  if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
645
- const { enforcement, denialSignatures, runnerFailureRules } = confined;
670
+ const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
646
671
  // Runner failure outranks denial because the command did not run at all.
647
672
  const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
648
673
  if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
@@ -652,6 +677,7 @@ export class WslShellExecutor extends LocalBashExecutor {
652
677
  mode,
653
678
  denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
654
679
  enforcement,
680
+ windowsDrive,
655
681
  },
656
682
  };
657
683
  }, (error) => {
@@ -716,6 +742,14 @@ export class WslFileSystem extends LocalFileSystem {
716
742
  * operations are unconfined.
717
743
  */
718
744
  sandbox: z.boolean().default(true),
745
+ /**
746
+ * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
747
+ * Windows drive from confined commands — same meaning and same honest
748
+ * limits as the `wsl-shell` key of the same name (its doc carries the
749
+ * full story). Confined fs mutations publish through the masked resident;
750
+ * reads and the share substrate are unaffected.
751
+ */
752
+ maskWindowsDrive: z.boolean().default(false),
719
753
  /**
720
754
  * Refuse any path that leaves the pinned distro. A WSL filesystem is a
721
755
  * whole-Linux view whose `/mnt/c` also reaches the Windows disk, so without
@@ -741,6 +775,19 @@ export class WslFileSystem extends LocalFileSystem {
741
775
  * only how many round trips it costs.
742
776
  */
743
777
  agent: z.boolean().default(true),
778
+ /**
779
+ * Which I/O substrate serves the file tools. `"agent"` (the default) —
780
+ * the resident in-distro agent (`lib/fs-substrate.js`): reads, writes,
781
+ * identities and watches run where the files live, symlinks and mode bits
782
+ * are native, and the mutation guard survives to the write's no-replace
783
+ * publication, kernel-enforced by the confined resident when the policy is
784
+ * confined. `"share"` — the opt-out: the Windows-side host filesystem
785
+ * stack over the distro's 9p share, with the symlink-retry and publication
786
+ * hooks that compensate for what the share cannot do. Both substrates
787
+ * fence writes with the same host-side policy check; neither changes
788
+ * `sandboxMode`'s honesty.
789
+ */
790
+ substrate: z.union(["share", "agent"]).default("agent"),
744
791
  });
745
792
 
746
793
  constructor(ctx, config) {
@@ -752,6 +799,10 @@ export class WslFileSystem extends LocalFileSystem {
752
799
  this.defaultMode = ctx.sandboxPolicy.defaultMode;
753
800
  /** targetKey -> canonical replacement target, for symlinked paths. */
754
801
  this.canonicalTargets = new Map();
802
+ /** The lazy agent-backed substrate, built on first use (see `agentSubstrate`). */
803
+ this.agentSubstrateInstance = undefined;
804
+ /** The distro-side bwrap usability probe, shared with the confined agents. */
805
+ this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
755
806
  // `dsh-fs-local` takes a Windows branch whenever the destination already
756
807
  // exists — which is how it knows to preserve the replaced file's security
757
808
  // descriptor, and what makes every write to an existing file fail here:
@@ -846,6 +897,78 @@ export class WslFileSystem extends LocalFileSystem {
846
897
  return this.agentInstance;
847
898
  }
848
899
 
900
+ /**
901
+ * The agent-backed substrate for `substrate: "agent"`, built on first use
902
+ * over the same resident agent the publication path shares.
903
+ * @returns {Promise<AgentSubstrate|undefined>} the substrate, or undefined
904
+ * when the share backend is configured.
905
+ */
906
+ async agentSubstrate() {
907
+ if (this.config.substrate !== "agent") return undefined;
908
+ if (this.agentSubstrateInstance === undefined) {
909
+ this.agentSubstrateInstance = new AgentSubstrate({
910
+ agent: await this.publicationAgent(),
911
+ distro: await this.distro(),
912
+ diffBasisMaxBytes: this.config.diffBasisMaxBytes,
913
+ agentFor: (policy) => this.mutationAgent(policy),
914
+ });
915
+ }
916
+ return this.agentSubstrateInstance;
917
+ }
918
+
919
+ /**
920
+ * The agent a MUTATION runs on under this resolved policy — stage-two
921
+ * routing. A confined policy addresses the confined resident whose bwrap
922
+ * profile binds exactly what the mode grants, so the kernel enforces what
923
+ * the check decided; an escalated write (`danger-full-access`) or a disabled
924
+ * sandbox addresses the plain resident, which is what those mean. A distro
925
+ * without a usable bwrap refuses confined mutations instead of downgrading
926
+ * them — the same closed failure the command path has.
927
+ * @param {{mode: string, workspaceRoot: string}|undefined} policy - the
928
+ * resolved file-effect policy, or undefined when the caller pre-checked.
929
+ * @returns {Promise<WslAgent>} the agent to publish through.
930
+ */
931
+ async mutationAgent(policy) {
932
+ if (!policy || !this.config.sandbox || policy.mode === "danger-full-access") {
933
+ return this.publicationAgent();
934
+ }
935
+ const distro = await this.distro();
936
+ if (!(await this.sandbox.usable(distro))) {
937
+ throw new SandboxUnavailableError(
938
+ policy.mode,
939
+ `bwrap is not usable inside distro "${distro}". Install it with ` +
940
+ `"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
941
+ `wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
942
+ );
943
+ }
944
+ return confinedAgent({ distro, wslPath: this.config.wslPath, policy, maskWindowsDrive: this.config.maskWindowsDrive });
945
+ }
946
+
947
+ /**
948
+ * Map a substrate error onto the peer's `FsError`, so the tool layer reads
949
+ * the same refusal dialect on both substrates. An agent that is out becomes
950
+ * an I/O error naming the cause — the share backend's "distro is gone" case
951
+ * is the same availability, but the message says which substrate failed.
952
+ * @param error - what the substrate raised.
953
+ * @returns {FsError} the error to surface.
954
+ */
955
+ peerError(error) {
956
+ if (error instanceof FsCodedError) return new FsError(error.message, error.code, { cause: error });
957
+ if (error instanceof SandboxUnavailableError) return new FsError(error.message, "FS_IO_ERROR", { cause: error });
958
+ const message = error instanceof Error ? error.message : String(error);
959
+ return new FsError(`the distro file substrate is unavailable: ${message}`, "FS_IO_ERROR", { cause: error instanceof Error ? error : undefined });
960
+ }
961
+
962
+ /**
963
+ * One resolved target's Linux path — the substrate's coordinate — falling
964
+ * back to the display spelling when the identity is not a distro share path.
965
+ * @param target - the resolved target.
966
+ * @returns the absolute Linux path.
967
+ */
968
+ linuxOf(target) {
969
+ return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
970
+ }
971
+
849
972
  /**
850
973
  * The pinned distro, resolving WSL's default on first use.
851
974
  * @returns the distro name.
@@ -892,8 +1015,19 @@ export class WslFileSystem extends LocalFileSystem {
892
1015
  * @returns the resolved `FsTarget`.
893
1016
  */
894
1017
  async resolve(path, opts) {
1018
+ const world = await this.worldPath(path, opts?.cwd);
1019
+ const sub = await this.agentSubstrate();
1020
+ if (sub !== undefined) {
1021
+ const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1022
+ if (linux === undefined) throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
1023
+ try {
1024
+ return await sub.resolve(linux, opts?.signal);
1025
+ } catch (error) {
1026
+ throw this.peerError(error);
1027
+ }
1028
+ }
895
1029
  const distro = await this.distro();
896
- const target = await super.resolve(await this.worldPath(path, opts?.cwd), opts);
1030
+ const target = await super.resolve(world, opts);
897
1031
  return { targetKey: target.targetKey, displayPath: toDisplayPath(target.targetKey, distro) };
898
1032
  }
899
1033
 
@@ -1002,22 +1136,86 @@ export class WslFileSystem extends LocalFileSystem {
1002
1136
  }
1003
1137
 
1004
1138
  async stat(target, signal) {
1139
+ const sub = await this.agentSubstrate();
1140
+ if (sub !== undefined) {
1141
+ try {
1142
+ return await sub.stat(target, signal);
1143
+ } catch (error) {
1144
+ throw this.peerError(error);
1145
+ }
1146
+ }
1005
1147
  return this.withCanonicalRetry(target, signal, (t) => super.stat(t, signal));
1006
1148
  }
1007
1149
 
1150
+ /**
1151
+ * No-follow stat one path. The agent substrate answers from the distro,
1152
+ * where a symlink is a first-class entry; the share backend keeps the
1153
+ * inherited host behaviour.
1154
+ * @param path - a Linux, UNC or Windows path.
1155
+ * @param opts - optional `cwd` override.
1156
+ * @param signal - optional cancellation.
1157
+ * @returns the path info, or undefined when absent.
1158
+ */
1159
+ async lstat(path, opts, signal) {
1160
+ const sub = await this.agentSubstrate();
1161
+ if (sub !== undefined) {
1162
+ const world = await this.worldPath(path, opts?.cwd);
1163
+ const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1164
+ if (linux === undefined) throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
1165
+ try {
1166
+ return await sub.lstat(linux, signal);
1167
+ } catch (error) {
1168
+ throw this.peerError(error);
1169
+ }
1170
+ }
1171
+ return super.lstat(path, opts, signal);
1172
+ }
1173
+
1008
1174
  async readText(target, signal) {
1175
+ const sub = await this.agentSubstrate();
1176
+ if (sub !== undefined) {
1177
+ try {
1178
+ return await sub.readText(target, signal);
1179
+ } catch (error) {
1180
+ throw this.peerError(error);
1181
+ }
1182
+ }
1009
1183
  return this.withCanonicalRetry(target, signal, (t) => super.readText(t, signal));
1010
1184
  }
1011
1185
 
1012
1186
  async streamText(target, signal) {
1187
+ const sub = await this.agentSubstrate();
1188
+ if (sub !== undefined) {
1189
+ try {
1190
+ return await sub.streamText(target, signal);
1191
+ } catch (error) {
1192
+ throw this.peerError(error);
1193
+ }
1194
+ }
1013
1195
  return this.withCanonicalRetry(target, signal, (t) => super.streamText(t, signal));
1014
1196
  }
1015
1197
 
1016
1198
  async readBytes(target, signal, maxBytes) {
1199
+ const sub = await this.agentSubstrate();
1200
+ if (sub !== undefined) {
1201
+ try {
1202
+ return await sub.readBytes(target, signal, maxBytes);
1203
+ } catch (error) {
1204
+ throw this.peerError(error);
1205
+ }
1206
+ }
1017
1207
  return this.withCanonicalRetry(target, signal, (t) => super.readBytes(t, signal, maxBytes));
1018
1208
  }
1019
1209
 
1020
1210
  async readByteRange(target, range, signal) {
1211
+ const sub = await this.agentSubstrate();
1212
+ if (sub !== undefined) {
1213
+ try {
1214
+ return await sub.readByteRange(target, range, signal);
1215
+ } catch (error) {
1216
+ throw this.peerError(error);
1217
+ }
1218
+ }
1021
1219
  return this.withCanonicalRetry(target, signal, (t) => super.readByteRange(t, range, signal));
1022
1220
  }
1023
1221
 
@@ -1031,6 +1229,16 @@ export class WslFileSystem extends LocalFileSystem {
1031
1229
  * @returns the directory rows.
1032
1230
  */
1033
1231
  async listDir(target, signal) {
1232
+ const sub = await this.agentSubstrate();
1233
+ if (sub !== undefined) {
1234
+ // The substrate's rows already carry Linux display paths and canonical
1235
+ // identities, so no share-side rewrite follows.
1236
+ try {
1237
+ return await sub.listDir(target, signal);
1238
+ } catch (error) {
1239
+ throw this.peerError(error);
1240
+ }
1241
+ }
1034
1242
  const entries = await this.withCanonicalRetry(target, signal, (t) => super.listDir(t, signal));
1035
1243
  const distro = await this.distro();
1036
1244
  return entries.map((entry) => ({
@@ -1111,9 +1319,25 @@ export class WslFileSystem extends LocalFileSystem {
1111
1319
  * @returns the write outcome.
1112
1320
  */
1113
1321
  async writeText(target, content, expected, signal, sandboxPolicy) {
1322
+ // The policy resolves once: the fence and the confined routing must see
1323
+ // the same grant, or the kernel would enforce something other than what
1324
+ // was checked.
1325
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1114
1326
  // The fence runs first and its fresh target is the one checked and written,
1115
1327
  // so a refusal costs no I/O and cannot be raced by a swapped symlink.
1116
- const fenced = await this.checkedTarget(target, sandboxPolicy);
1328
+ const fenced = await this.checkedTarget(target, policy);
1329
+ const sub = await this.agentSubstrate();
1330
+ if (sub !== undefined) {
1331
+ // The agent substrate keeps the guard: the distro-side no-replace link
1332
+ // is an atomic primitive, so the check-then-write window the share
1333
+ // backend documents closes here, exactly as on a Linux host. The policy
1334
+ // also selects the confined resident whose mount table matches it.
1335
+ try {
1336
+ return await sub.writeText(fenced, content, expected, signal, policy);
1337
+ } catch (error) {
1338
+ throw this.peerError(error);
1339
+ }
1340
+ }
1117
1341
  await this.assertGuard(fenced, expected, signal);
1118
1342
  // The guard is deliberately not forwarded: the host backend would publish a
1119
1343
  // guarded creation with a hard link, which this filesystem rejects. Once
@@ -1133,7 +1357,16 @@ export class WslFileSystem extends LocalFileSystem {
1133
1357
  * @returns the edit outcome.
1134
1358
  */
1135
1359
  async editText(target, edit, expected, signal, sandboxPolicy) {
1136
- const fenced = await this.checkedTarget(target, sandboxPolicy);
1360
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1361
+ const fenced = await this.checkedTarget(target, policy);
1362
+ const sub = await this.agentSubstrate();
1363
+ if (sub !== undefined) {
1364
+ try {
1365
+ return await sub.editText(fenced, edit, expected, signal, policy);
1366
+ } catch (error) {
1367
+ throw this.peerError(error);
1368
+ }
1369
+ }
1137
1370
  await this.assertGuard(fenced, expected, signal);
1138
1371
  return super.editText(fenced, edit, undefined, signal);
1139
1372
  }
package/lib/sandbox.js CHANGED
@@ -32,6 +32,12 @@ import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
32
32
  import { createProbeCache } from "./probe-cache.js";
33
33
  import { toLinuxPath } from "./paths.js";
34
34
  import { runCapture } from "./wsl.js";
35
+ import { bwrapArgvPrefix, bwrapProfileArgs } from "./bwrap.js";
36
+
37
+ // The profile and the argv prefix are also what `lib/agent-confined.js` builds
38
+ // for its residents, so both live in the peer-free `lib/bwrap.js` and are
39
+ // re-exported here, where the sandbox's callers have always found them.
40
+ export { bwrapProfileArgs };
35
41
 
36
42
  /**
37
43
  * bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
@@ -60,6 +66,22 @@ const RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
60
66
  */
61
67
  const ENFORCEMENT = "partial";
62
68
 
69
+ /**
70
+ * The one mitigation the profile can offer, and its honest ceiling.
71
+ *
72
+ * `maskWindowsDrive` mounts an empty tmpfs over `/mnt`: the Windows drive's
73
+ * files vanish from the command's view, which takes the drive's data off the
74
+ * table and makes the on-drive executables unreachable. It does NOT make the
75
+ * run `full`: binfmt interop dispatches on file CONTENT, so a command that can
76
+ * write the workspace (which `workspace-write` grants) can write a PE there
77
+ * and execute it — binfmt launches it as a Windows process outside the Linux
78
+ * sandbox regardless of where the file sits. The only complete closures are
79
+ * distro-level (`[interop] enabled=false` in `wsl.conf`, which costs every
80
+ * legitimate interop user their tooling) — so the provider reports `partial`
81
+ * either way, and the mask is a decision an operator makes to shrink the hole,
82
+ * recorded as the `windowsDrive: "masked"` fact the model can see.
83
+ */
84
+
63
85
  /**
64
86
  * Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
65
87
  * failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
@@ -67,29 +89,6 @@ const ENFORCEMENT = "partial";
67
89
  */
68
90
  const probeVerdicts = createProbeCache();
69
91
 
70
- /**
71
- * Build the bwrap profile arguments for one file-effect policy, exactly as
72
- * upstream's `bwrapProfileArgs` does.
73
- *
74
- * `--ro-bind / /` makes the whole distro read-only, `--dev /dev` supplies the
75
- * `/dev/null` sink a shell needs, `--unshare-pid` keeps the sandbox's process
76
- * view its own, and `--proc /proc` gives it a matching `/proc`. Under
77
- * `workspace-write` the workspace is bound read-write and `/tmp` becomes an
78
- * ephemeral tmpfs — the temp area the mode promises, without exposing the
79
- * distro's real `/tmp`.
80
- *
81
- * @param policy - the policy to express as bwrap mounts.
82
- * @returns profile arguments, before the `--` separator and the command argv.
83
- */
84
- export function bwrapProfileArgs(policy) {
85
- const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
86
- if (policy.mode === "workspace-write") {
87
- args.push("--tmpfs", "/tmp");
88
- args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
89
- }
90
- return args;
91
- }
92
-
93
92
  /**
94
93
  * Confine commands inside one distro with `bwrap`.
95
94
  *
@@ -100,9 +99,14 @@ export function bwrapProfileArgs(policy) {
100
99
  export class WslSandbox {
101
100
  /**
102
101
  * @param config - the `wsl.exe` path, overridable for a non-standard install.
102
+ * @param config.maskWindowsDrive - shadow `/mnt` with an empty tmpfs in every
103
+ * profile this sandbox builds, hiding the Windows drive from confined
104
+ * commands (see the module doc above {@link ENFORCEMENT} for what that
105
+ * does and does not close).
103
106
  */
104
107
  constructor(config = {}) {
105
108
  this.wslPath = config.wslPath ?? "wsl.exe";
109
+ this.maskWindowsDrive = config.maskWindowsDrive ?? false;
106
110
  }
107
111
 
108
112
  /**
@@ -123,8 +127,8 @@ export class WslSandbox {
123
127
  async usable(distro, signal) {
124
128
  const key = `${this.wslPath}\u0000${distro}`;
125
129
  return probeVerdicts.run(key, async () => {
126
- const profile = bwrapProfileArgs({ mode: "read-only", workspaceRoot: "/" });
127
- await runCapture([this.wslPath, "-d", distro, "--exec", "bwrap", ...profile, "--", "true"], signal);
130
+ const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
131
+ await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
128
132
  return true;
129
133
  });
130
134
  }
@@ -152,12 +156,13 @@ export class WslSandbox {
152
156
  );
153
157
  }
154
158
  const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
155
- const profile = bwrapProfileArgs({ ...policy, workspaceRoot });
159
+ const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
156
160
  return {
157
- argv: ["bwrap", ...profile, "--", ...argv],
161
+ argv: [...prefix, ...argv],
158
162
  enforcement: ENFORCEMENT,
159
163
  denialSignatures: DENIAL_SIGNATURES,
160
164
  runnerFailureRules: RUNNER_FAILURE_RULES,
165
+ windowsDrive: this.maskWindowsDrive ? "masked" : "visible",
161
166
  };
162
167
  }
163
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -40,6 +40,8 @@
40
40
  "docs/CONFIGURATION.md",
41
41
  "docs/LIMITATIONS.md",
42
42
  "docs/PARITY.md",
43
+ "docs/PEER-PARITY.md",
44
+ "docs/UPSTREAM-FSIO-EXPORT.md",
43
45
  "docs/ARCHITECTURE.md",
44
46
  "CHANGELOG.md",
45
47
  "CONTRIBUTING.md",
@@ -51,7 +53,7 @@
51
53
  "test": "npm run lint:style && npm run test:syntax && npm run test:unit",
52
54
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs",
53
55
  "test:syntax": "node test/syntax.mjs",
54
- "test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-publish.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs",
56
+ "test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/fs-publish.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs",
55
57
  "lint:style": "node test/style.mjs",
56
58
  "prepublishOnly": "npm test",
57
59
  "probe": "bash test/probe/run.sh",
@@ -64,6 +66,7 @@
64
66
  "probe:sandbox-shell": "bash test/probe/sandbox-shell.sh",
65
67
  "sync:windows": "bash test/probe/sync-to-windows.sh",
66
68
  "probe:watch": "bash test/probe/watch.sh",
69
+ "probe:substrate": "bash test/probe/substrate.sh",
67
70
  "probe:exec": "bash test/probe/exec.sh",
68
71
  "bootstrap": "bash scripts/bootstrap.sh"
69
72
  },