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/CHANGELOG.md +294 -0
- package/README.md +2 -0
- package/agent/wsl-agent.sh +413 -27
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.md +37 -1
- package/docs/CONFIGURATION.md +4 -1
- package/docs/LIMITATIONS.md +67 -1
- package/docs/PARITY.md +5 -4
- 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 +87 -0
- package/lib/agent-exec.js +22 -12
- package/lib/agent-protocol.js +65 -7
- package/lib/agent.js +279 -64
- package/lib/bwrap.js +91 -0
- package/lib/fs-decisions.js +132 -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 +271 -26
- package/lib/paths.js +14 -0
- package/lib/sandbox.js +32 -27
- package/lib/shell-env.js +6 -1
- package/package.json +6 -3
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
|
-
|
|
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(
|
|
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
|
|
923
|
-
if (
|
|
924
|
-
if (mode
|
|
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
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
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,
|
|
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
|
|
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
|
|
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/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,
|