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/CHANGELOG.md +165 -0
- package/README.md +2 -0
- package/agent/wsl-agent.sh +368 -26
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +4 -1
- package/docs/LIMITATIONS.md +60 -0
- package/docs/PEER-PARITY.md +60 -0
- package/docs/README.zh.md +2 -0
- package/docs/UPSTREAM-FSIO-EXPORT.md +87 -0
- package/lib/agent-confined.js +81 -0
- package/lib/agent-exec.js +22 -12
- package/lib/agent-protocol.js +65 -7
- package/lib/agent.js +211 -61
- package/lib/bwrap.js +74 -0
- package/lib/fs-substrate.js +285 -0
- package/lib/fsio-agent.js +481 -0
- package/lib/fsio-text.js +153 -0
- package/lib/index.js +239 -6
- package/lib/sandbox.js +32 -27
- package/package.json +5 -2
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(
|
|
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,
|
|
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
|
|
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
|
|
127
|
-
await runCapture([this.wslPath, "-d", distro, "--exec",
|
|
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
|
|
159
|
+
const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
|
|
156
160
|
return {
|
|
157
|
-
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.
|
|
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
|
},
|