dsh-plugin-wsl-env 0.2.0 → 0.4.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,8 @@ 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 { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
47
+ import { guardRefusal, isConfinedMutation, mutationModeRefusal, outsideDistroRefusal, substrateFailure, workspaceWriteDenial } from "./fs-decisions.js";
47
48
  import { WslSandbox } from "./sandbox.js";
48
49
  import { armDistroWatcher } from "./watcher.js";
49
50
  import { WslAgent } from "./agent.js";
@@ -51,6 +52,9 @@ import { AgentUnavailableError } from "./agent-errors.js";
51
52
  import { publicationArgv } from "./fs-publish.js";
52
53
  import { agentExecutionHandle } from "./agent-exec.js";
53
54
  import { sharedAgent } from "./agent-shared.js";
55
+ import { AgentSubstrate } from "./fs-substrate.js";
56
+ import { FsCodedError } from "./fsio-text.js";
57
+ import { confinedAgent } from "./agent-confined.js";
54
58
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
55
59
  // can use them without pulling this file's executor and filesystem peers in.
56
60
  import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
@@ -172,6 +176,20 @@ export class WslShellExecutor extends LocalBashExecutor {
172
176
  * unconfined and `sandboxMode` is `undefined`, so the model is told so.
173
177
  */
174
178
  sandbox: z.boolean().default(true),
179
+ /**
180
+ * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
181
+ * Windows drive from confined commands: its files cannot be read or
182
+ * exfiltrated and its executables cannot be launched through interop —
183
+ * the hole that lets a "sandboxed" command reach your full Windows token.
184
+ * A narrowing, not a closure (a command can still write an executable
185
+ * into the workspace and run it — binfmt dispatches on content, not
186
+ * location), so enforcement stays `partial` and the sandbox fact reports
187
+ * `windowsDrive: "masked"`. Costs: confined commands cannot read or touch
188
+ * anything under `/mnt`, and tools that probe the drive degrade. The
189
+ * complete closure is distro-level: `[interop] enabled=false` in
190
+ * `wsl.conf`.
191
+ */
192
+ maskWindowsDrive: z.boolean().default(false),
175
193
  /**
176
194
  * Default workdir for a request that names none; accepts `/home/andy/proj`,
177
195
  * a UNC path, or `C:\...`. Empty — the default — means the distro user's
@@ -447,6 +465,7 @@ export class WslShellExecutor extends LocalBashExecutor {
447
465
  mode: facts.mode,
448
466
  denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
449
467
  enforcement: facts.enforcement,
468
+ ...(facts.windowsDrive ? { windowsDrive: facts.windowsDrive } : {}),
450
469
  ...(runnerFailed ? { runnerFailed } : {}),
451
470
  };
452
471
  }
@@ -516,6 +535,7 @@ export class WslShellExecutor extends LocalBashExecutor {
516
535
  const distro = await this.distro();
517
536
  const shell = await this.shell();
518
537
  const resolved = await this.withDefaultWorkdir(spec);
538
+ this.throwIfWorkdirInAnotherDistro(resolved.workdir, distro);
519
539
  if (this.config.agent) {
520
540
  try {
521
541
  return await this.executeViaAgent(resolved, distro, shell);
@@ -528,6 +548,26 @@ export class WslShellExecutor extends LocalBashExecutor {
528
548
  return this.executeOneShot(resolved, distro, shell);
529
549
  }
530
550
 
551
+ /**
552
+ * Refuse a workdir that names another WSL distro — the shell-side twin of
553
+ * the filesystem's `FS_OUTSIDE_DISTRO` fence. Left unguarded, `toLinuxPath`
554
+ * strips the UNC's distro name and the command runs inside THIS distro
555
+ * against whatever tree happens to share the path, silently, while the fs
556
+ * side refuses the same workspace outright. The wording mirrors the fs
557
+ * refusal (minus `restrictToDistro`, which is an fs-only key).
558
+ * @param workdir - the resolved workdir (UNC, Linux path, or Windows path).
559
+ * @param distro - the pinned distro name.
560
+ * @throws Error naming both distros and both ways out.
561
+ */
562
+ throwIfWorkdirInAnotherDistro(workdir, distro) {
563
+ if (!isAnotherDistrosUnc(workdir, distro)) return;
564
+ throw new Error(
565
+ `"${workdir}" names a workspace in another WSL distro, and this profile pins its commands to "${distro}" — ` +
566
+ `running them here would act on a different machine's tree. Open a session in that distro, ` +
567
+ `or point cwd/workdir at this distro.`,
568
+ );
569
+ }
570
+
531
571
  /**
532
572
  * The agent-backed execution path: one long-lived in-distro process, the
533
573
  * confinement argv unchanged, the handle shape unchanged. Any
@@ -552,6 +592,10 @@ export class WslShellExecutor extends LocalBashExecutor {
552
592
  cwd: toLinuxPath(resolved.workdir, { distro }),
553
593
  argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
554
594
  timeoutMs: resolved.timeoutMs,
595
+ // The agent enforces the collect budget distro-side (the EXEC frame
596
+ // carries it); `maxSpillBytes` stays one-shot-only — the agent path has
597
+ // no spill files by design, its capture is bounded instead.
598
+ maxOutputBytes: this.config.maxOutputBytes,
555
599
  signal: resolved.signal,
556
600
  onStarted: confined !== undefined
557
601
  ? (proc) => {
@@ -562,6 +606,7 @@ export class WslShellExecutor extends LocalBashExecutor {
562
606
  runnerFailureRules: confined.runnerFailureRules,
563
607
  runnerProgram: confined.argv[0],
564
608
  workdir: resolved.workdir,
609
+ windowsDrive: confined.windowsDrive,
565
610
  });
566
611
  }
567
612
  : undefined,
@@ -571,7 +616,7 @@ export class WslShellExecutor extends LocalBashExecutor {
571
616
  this.throwIfWorkdirMissing(result, resolved.workdir);
572
617
  if (this.sandbox === undefined) return result;
573
618
  if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
574
- const { enforcement, denialSignatures, runnerFailureRules } = confined;
619
+ const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
575
620
  const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
576
621
  if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
577
622
  return {
@@ -580,6 +625,7 @@ export class WslShellExecutor extends LocalBashExecutor {
580
625
  mode,
581
626
  denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
582
627
  enforcement,
628
+ windowsDrive,
583
629
  },
584
630
  };
585
631
  }, (error) => {
@@ -635,6 +681,7 @@ export class WslShellExecutor extends LocalBashExecutor {
635
681
  runnerFailureRules: confined.runnerFailureRules,
636
682
  runnerProgram: confined.argv[0],
637
683
  workdir: resolved.workdir,
684
+ windowsDrive: confined.windowsDrive,
638
685
  });
639
686
  },
640
687
  );
@@ -642,7 +689,7 @@ export class WslShellExecutor extends LocalBashExecutor {
642
689
  this.throwIfDistroUnavailable(result, distro);
643
690
  this.throwIfWorkdirMissing(result, resolved.workdir);
644
691
  if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
645
- const { enforcement, denialSignatures, runnerFailureRules } = confined;
692
+ const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
646
693
  // Runner failure outranks denial because the command did not run at all.
647
694
  const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
648
695
  if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
@@ -652,6 +699,7 @@ export class WslShellExecutor extends LocalBashExecutor {
652
699
  mode,
653
700
  denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
654
701
  enforcement,
702
+ windowsDrive,
655
703
  },
656
704
  };
657
705
  }, (error) => {
@@ -716,6 +764,14 @@ export class WslFileSystem extends LocalFileSystem {
716
764
  * operations are unconfined.
717
765
  */
718
766
  sandbox: z.boolean().default(true),
767
+ /**
768
+ * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
769
+ * Windows drive from confined commands — same meaning and same honest
770
+ * limits as the `wsl-shell` key of the same name (its doc carries the
771
+ * full story). Confined fs mutations publish through the masked resident;
772
+ * reads and the share substrate are unaffected.
773
+ */
774
+ maskWindowsDrive: z.boolean().default(false),
719
775
  /**
720
776
  * Refuse any path that leaves the pinned distro. A WSL filesystem is a
721
777
  * whole-Linux view whose `/mnt/c` also reaches the Windows disk, so without
@@ -741,6 +797,19 @@ export class WslFileSystem extends LocalFileSystem {
741
797
  * only how many round trips it costs.
742
798
  */
743
799
  agent: z.boolean().default(true),
800
+ /**
801
+ * Which I/O substrate serves the file tools. `"agent"` (the default) —
802
+ * the resident in-distro agent (`lib/fs-substrate.js`): reads, writes,
803
+ * identities and watches run where the files live, symlinks and mode bits
804
+ * are native, and the mutation guard survives to the write's no-replace
805
+ * publication, kernel-enforced by the confined resident when the policy is
806
+ * confined. `"share"` — the opt-out: the Windows-side host filesystem
807
+ * stack over the distro's 9p share, with the symlink-retry and publication
808
+ * hooks that compensate for what the share cannot do. Both substrates
809
+ * fence writes with the same host-side policy check; neither changes
810
+ * `sandboxMode`'s honesty.
811
+ */
812
+ substrate: z.union(["share", "agent"]).default("agent"),
744
813
  });
745
814
 
746
815
  constructor(ctx, config) {
@@ -752,6 +821,10 @@ export class WslFileSystem extends LocalFileSystem {
752
821
  this.defaultMode = ctx.sandboxPolicy.defaultMode;
753
822
  /** targetKey -> canonical replacement target, for symlinked paths. */
754
823
  this.canonicalTargets = new Map();
824
+ /** The lazy agent-backed substrate, built on first use (see `agentSubstrate`). */
825
+ this.agentSubstrateInstance = undefined;
826
+ /** The distro-side bwrap usability probe, shared with the confined agents. */
827
+ this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
755
828
  // `dsh-fs-local` takes a Windows branch whenever the destination already
756
829
  // exists — which is how it knows to preserve the replaced file's security
757
830
  // descriptor, and what makes every write to an existing file fail here:
@@ -846,6 +919,78 @@ export class WslFileSystem extends LocalFileSystem {
846
919
  return this.agentInstance;
847
920
  }
848
921
 
922
+ /**
923
+ * The agent-backed substrate for `substrate: "agent"`, built on first use
924
+ * over the same resident agent the publication path shares.
925
+ * @returns {Promise<AgentSubstrate|undefined>} the substrate, or undefined
926
+ * when the share backend is configured.
927
+ */
928
+ async agentSubstrate() {
929
+ if (this.config.substrate !== "agent") return undefined;
930
+ if (this.agentSubstrateInstance === undefined) {
931
+ this.agentSubstrateInstance = new AgentSubstrate({
932
+ agent: await this.publicationAgent(),
933
+ distro: await this.distro(),
934
+ diffBasisMaxBytes: this.config.diffBasisMaxBytes,
935
+ agentFor: (policy) => this.mutationAgent(policy),
936
+ });
937
+ }
938
+ return this.agentSubstrateInstance;
939
+ }
940
+
941
+ /**
942
+ * The agent a MUTATION runs on under this resolved policy — stage-two
943
+ * routing. A confined policy addresses the confined resident whose bwrap
944
+ * profile binds exactly what the mode grants, so the kernel enforces what
945
+ * the check decided; an escalated write (`danger-full-access`) or a disabled
946
+ * sandbox addresses the plain resident, which is what those mean. A distro
947
+ * without a usable bwrap refuses confined mutations instead of downgrading
948
+ * them — the same closed failure the command path has.
949
+ * @param {{mode: string, workspaceRoot: string}|undefined} policy - the
950
+ * resolved file-effect policy, or undefined when the caller pre-checked.
951
+ * @returns {Promise<WslAgent>} the agent to publish through.
952
+ */
953
+ async mutationAgent(policy) {
954
+ if (!isConfinedMutation({ policy, sandboxEnabled: this.config.sandbox })) {
955
+ return this.publicationAgent();
956
+ }
957
+ const distro = await this.distro();
958
+ if (!(await this.sandbox.usable(distro))) {
959
+ throw new SandboxUnavailableError(
960
+ policy.mode,
961
+ `bwrap is not usable inside distro "${distro}". Install it with ` +
962
+ `"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
963
+ `wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
964
+ );
965
+ }
966
+ return confinedAgent({ distro, wslPath: this.config.wslPath, policy, maskWindowsDrive: this.config.maskWindowsDrive });
967
+ }
968
+
969
+ /**
970
+ * Map a substrate error onto the peer's `FsError`, so the tool layer reads
971
+ * the same refusal dialect on both substrates. An agent that is out becomes
972
+ * an I/O error naming the cause — the share backend's "distro is gone" case
973
+ * is the same availability, but the message says which substrate failed.
974
+ * @param error - what the substrate raised.
975
+ * @returns {FsError} the error to surface.
976
+ */
977
+ peerError(error) {
978
+ if (error instanceof FsCodedError) return new FsError(error.message, error.code, { cause: error });
979
+ if (error instanceof SandboxUnavailableError) return new FsError(error.message, "FS_IO_ERROR", { cause: error });
980
+ const { message, code, cause } = substrateFailure(error);
981
+ return new FsError(message, code, cause === undefined ? {} : { cause });
982
+ }
983
+
984
+ /**
985
+ * One resolved target's Linux path — the substrate's coordinate — falling
986
+ * back to the display spelling when the identity is not a distro share path.
987
+ * @param target - the resolved target.
988
+ * @returns the absolute Linux path.
989
+ */
990
+ linuxOf(target) {
991
+ return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
992
+ }
993
+
849
994
  /**
850
995
  * The pinned distro, resolving WSL's default on first use.
851
996
  * @returns the distro name.
@@ -871,15 +1016,12 @@ export class WslFileSystem extends LocalFileSystem {
871
1016
  base = await distroHome(this);
872
1017
  }
873
1018
  const world = toWorldPath(path, { distro, cwd: base });
874
- if (this.config.restrictToDistro && isWslUnc(world) && !isUnderDistro(world, distro)) {
1019
+ const refusal = outsideDistroRefusal({ path, world, distro, restrictToDistro: this.config.restrictToDistro });
1020
+ if (refusal !== null) {
875
1021
  // Deliberately NOT `FS_SANDBOX_DENIED`. The file-tool layer turns that code
876
1022
  // into a denial marker plus an escalation offer, and no wider permission can
877
1023
  // lift this fence: it is a configuration choice, not a sandbox decision.
878
- throw new FsError(
879
- `"${path}" names a path in another WSL distro, and this profile pins its filesystem to "${distro}" ` +
880
- `(restrictToDistro: true). Open a session in that distro, or set restrictToDistro: false.`,
881
- "FS_OUTSIDE_DISTRO",
882
- );
1024
+ throw new FsError(refusal.message, refusal.code);
883
1025
  }
884
1026
  return world;
885
1027
  }
@@ -892,8 +1034,19 @@ export class WslFileSystem extends LocalFileSystem {
892
1034
  * @returns the resolved `FsTarget`.
893
1035
  */
894
1036
  async resolve(path, opts) {
1037
+ const world = await this.worldPath(path, opts?.cwd);
1038
+ const sub = await this.agentSubstrate();
1039
+ if (sub !== undefined) {
1040
+ const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1041
+ if (linux === undefined) throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
1042
+ try {
1043
+ return await sub.resolve(linux, opts?.signal);
1044
+ } catch (error) {
1045
+ throw this.peerError(error);
1046
+ }
1047
+ }
895
1048
  const distro = await this.distro();
896
- const target = await super.resolve(await this.worldPath(path, opts?.cwd), opts);
1049
+ const target = await super.resolve(world, opts);
897
1050
  return { targetKey: target.targetKey, displayPath: toDisplayPath(target.targetKey, distro) };
898
1051
  }
899
1052
 
@@ -919,17 +1072,16 @@ export class WslFileSystem extends LocalFileSystem {
919
1072
  async checkedTarget(target, sandboxPolicy) {
920
1073
  if (!this.config.sandbox) return target;
921
1074
  const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
922
- const { mode } = policy;
923
- if (mode === "danger-full-access") return target;
924
- if (mode === "read-only") {
925
- throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
926
- }
1075
+ const modeRefusal = mutationModeRefusal(policy.mode, target.displayPath);
1076
+ if (modeRefusal !== null) throw new FsError(modeRefusal.message, modeRefusal.code);
1077
+ if (policy.mode !== "workspace-write") return target;
927
1078
  const fresh = await this.resolve(target.displayPath);
928
1079
  const distro = await this.distro();
929
1080
  for (const root of wslWritableRoots(policy, distro)) {
930
1081
  if (isWorldPathUnder(fresh.targetKey, root, { distro })) return fresh;
931
1082
  }
932
- throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
1083
+ const denial = workspaceWriteDenial(target.displayPath);
1084
+ throw new FsError(denial.message, denial.code);
933
1085
  }
934
1086
 
935
1087
  /**
@@ -1002,22 +1154,86 @@ export class WslFileSystem extends LocalFileSystem {
1002
1154
  }
1003
1155
 
1004
1156
  async stat(target, signal) {
1157
+ const sub = await this.agentSubstrate();
1158
+ if (sub !== undefined) {
1159
+ try {
1160
+ return await sub.stat(target, signal);
1161
+ } catch (error) {
1162
+ throw this.peerError(error);
1163
+ }
1164
+ }
1005
1165
  return this.withCanonicalRetry(target, signal, (t) => super.stat(t, signal));
1006
1166
  }
1007
1167
 
1168
+ /**
1169
+ * No-follow stat one path. The agent substrate answers from the distro,
1170
+ * where a symlink is a first-class entry; the share backend keeps the
1171
+ * inherited host behaviour.
1172
+ * @param path - a Linux, UNC or Windows path.
1173
+ * @param opts - optional `cwd` override.
1174
+ * @param signal - optional cancellation.
1175
+ * @returns the path info, or undefined when absent.
1176
+ */
1177
+ async lstat(path, opts, signal) {
1178
+ const sub = await this.agentSubstrate();
1179
+ if (sub !== undefined) {
1180
+ const world = await this.worldPath(path, opts?.cwd);
1181
+ const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1182
+ if (linux === undefined) throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
1183
+ try {
1184
+ return await sub.lstat(linux, signal);
1185
+ } catch (error) {
1186
+ throw this.peerError(error);
1187
+ }
1188
+ }
1189
+ return super.lstat(path, opts, signal);
1190
+ }
1191
+
1008
1192
  async readText(target, signal) {
1193
+ const sub = await this.agentSubstrate();
1194
+ if (sub !== undefined) {
1195
+ try {
1196
+ return await sub.readText(target, signal);
1197
+ } catch (error) {
1198
+ throw this.peerError(error);
1199
+ }
1200
+ }
1009
1201
  return this.withCanonicalRetry(target, signal, (t) => super.readText(t, signal));
1010
1202
  }
1011
1203
 
1012
1204
  async streamText(target, signal) {
1205
+ const sub = await this.agentSubstrate();
1206
+ if (sub !== undefined) {
1207
+ try {
1208
+ return await sub.streamText(target, signal);
1209
+ } catch (error) {
1210
+ throw this.peerError(error);
1211
+ }
1212
+ }
1013
1213
  return this.withCanonicalRetry(target, signal, (t) => super.streamText(t, signal));
1014
1214
  }
1015
1215
 
1016
1216
  async readBytes(target, signal, maxBytes) {
1217
+ const sub = await this.agentSubstrate();
1218
+ if (sub !== undefined) {
1219
+ try {
1220
+ return await sub.readBytes(target, signal, maxBytes);
1221
+ } catch (error) {
1222
+ throw this.peerError(error);
1223
+ }
1224
+ }
1017
1225
  return this.withCanonicalRetry(target, signal, (t) => super.readBytes(t, signal, maxBytes));
1018
1226
  }
1019
1227
 
1020
1228
  async readByteRange(target, range, signal) {
1229
+ const sub = await this.agentSubstrate();
1230
+ if (sub !== undefined) {
1231
+ try {
1232
+ return await sub.readByteRange(target, range, signal);
1233
+ } catch (error) {
1234
+ throw this.peerError(error);
1235
+ }
1236
+ }
1021
1237
  return this.withCanonicalRetry(target, signal, (t) => super.readByteRange(t, range, signal));
1022
1238
  }
1023
1239
 
@@ -1031,6 +1247,16 @@ export class WslFileSystem extends LocalFileSystem {
1031
1247
  * @returns the directory rows.
1032
1248
  */
1033
1249
  async listDir(target, signal) {
1250
+ const sub = await this.agentSubstrate();
1251
+ if (sub !== undefined) {
1252
+ // The substrate's rows already carry Linux display paths and canonical
1253
+ // identities, so no share-side rewrite follows.
1254
+ try {
1255
+ return await sub.listDir(target, signal);
1256
+ } catch (error) {
1257
+ throw this.peerError(error);
1258
+ }
1259
+ }
1034
1260
  const entries = await this.withCanonicalRetry(target, signal, (t) => super.listDir(t, signal));
1035
1261
  const distro = await this.distro();
1036
1262
  return entries.map((entry) => ({
@@ -1089,14 +1315,8 @@ export class WslFileSystem extends LocalFileSystem {
1089
1315
  async assertGuard(target, expected, signal) {
1090
1316
  if (expected === undefined) return;
1091
1317
  const existing = await this.stat(target, signal);
1092
- if (expected.kind === "replaceIfVersion") {
1093
- if (existing === undefined) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
1094
- if (existing.version !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
1095
- return;
1096
- }
1097
- if (expected.kind === "createIfAbsent" && existing !== undefined) {
1098
- throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
1099
- }
1318
+ const refusal = guardRefusal(expected, existing, target.displayPath);
1319
+ if (refusal !== null) throw new FsError(refusal.message, refusal.code);
1100
1320
  }
1101
1321
 
1102
1322
  /**
@@ -1111,9 +1331,25 @@ export class WslFileSystem extends LocalFileSystem {
1111
1331
  * @returns the write outcome.
1112
1332
  */
1113
1333
  async writeText(target, content, expected, signal, sandboxPolicy) {
1334
+ // The policy resolves once: the fence and the confined routing must see
1335
+ // the same grant, or the kernel would enforce something other than what
1336
+ // was checked.
1337
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1114
1338
  // The fence runs first and its fresh target is the one checked and written,
1115
1339
  // so a refusal costs no I/O and cannot be raced by a swapped symlink.
1116
- const fenced = await this.checkedTarget(target, sandboxPolicy);
1340
+ const fenced = await this.checkedTarget(target, policy);
1341
+ const sub = await this.agentSubstrate();
1342
+ if (sub !== undefined) {
1343
+ // The agent substrate keeps the guard: the distro-side no-replace link
1344
+ // is an atomic primitive, so the check-then-write window the share
1345
+ // backend documents closes here, exactly as on a Linux host. The policy
1346
+ // also selects the confined resident whose mount table matches it.
1347
+ try {
1348
+ return await sub.writeText(fenced, content, expected, signal, policy);
1349
+ } catch (error) {
1350
+ throw this.peerError(error);
1351
+ }
1352
+ }
1117
1353
  await this.assertGuard(fenced, expected, signal);
1118
1354
  // The guard is deliberately not forwarded: the host backend would publish a
1119
1355
  // guarded creation with a hard link, which this filesystem rejects. Once
@@ -1133,7 +1369,16 @@ export class WslFileSystem extends LocalFileSystem {
1133
1369
  * @returns the edit outcome.
1134
1370
  */
1135
1371
  async editText(target, edit, expected, signal, sandboxPolicy) {
1136
- const fenced = await this.checkedTarget(target, sandboxPolicy);
1372
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1373
+ const fenced = await this.checkedTarget(target, policy);
1374
+ const sub = await this.agentSubstrate();
1375
+ if (sub !== undefined) {
1376
+ try {
1377
+ return await sub.editText(fenced, edit, expected, signal, policy);
1378
+ } catch (error) {
1379
+ throw this.peerError(error);
1380
+ }
1381
+ }
1137
1382
  await this.assertGuard(fenced, expected, signal);
1138
1383
  return super.editText(fenced, edit, undefined, signal);
1139
1384
  }
package/lib/paths.js CHANGED
@@ -165,6 +165,20 @@ export function isUnderDistro(uncPath, distro) {
165
165
  return parsed !== undefined && parsed.distro.toLowerCase() === String(distro).toLowerCase();
166
166
  }
167
167
 
168
+ /**
169
+ * A WSL UNC that names a distro OTHER than the pinned one — the shape both
170
+ * shell seams refuse: the shell executor will not run commands in it (running
171
+ * there would mean silently switching distros on path similarity), and the
172
+ * shell-env facts describe only the pinned distro, so a foreign session gets
173
+ * no facts rather than another distro's home and shell.
174
+ * @param uncPath - the candidate path (any spelling; non-UNC values are not foreign).
175
+ * @param distro - the pinned distro name.
176
+ * @returns true when the value is a WSL UNC under a different distro.
177
+ */
178
+ export function isAnotherDistrosUnc(value, distro) {
179
+ return isWslUnc(value) && !isUnderDistro(value, distro);
180
+ }
181
+
168
182
  /**
169
183
  * Translate a Windows drive path into its WSL interop mount.
170
184
  * @param winPath - e.g. `C:\Users\andyz\Documents`.
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/lib/shell-env.js CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import z from "@deepseek-ai/schemastery";
27
- import { isWslUnc } from "./paths.js";
27
+ import { isAnotherDistrosUnc, isWslUnc } from "./paths.js";
28
28
  import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
29
29
  import { sharedAgent } from "./agent-shared.js";
30
30
  import { listeningPorts } from "./ports.js";
@@ -82,6 +82,11 @@ export async function apply(ctx, config = {}) {
82
82
  resolve: (execution) => {
83
83
  const cwd = execution?.agent?.session?.header?.cwd;
84
84
  if (!isWslUnc(cwd)) return {};
85
+ // The facts describe ONE distro. A workspace under another distro's UNC
86
+ // is not that distro — the shell refuses its commands outright (the
87
+ // cross-distro guard) — so the honest resolution is no facts at all,
88
+ // not another distro's home and shell presented as this session's.
89
+ if (isAnotherDistrosUnc(cwd, distro)) return {};
85
90
  return {
86
91
  DSH_WSL_DISTRO: distro,
87
92
  DSH_WSL_SHELL: shell,