- Home
- Plugins
- Security & Permissions
- dsh-sandbox-mxc
dsh-sandbox-mxc
bitsmug/dsh-sandbox-mxc
DeepSeek Harness sandbox provider backed by MXC (Microsoft eXecution Containers): wraps ctx.sandbox.confine() argv as `wxc-exec --config-base64 <policy> -- <command>` on the BaseContainer backend. One runtime dependency (@microsoft/mxc-sdk, for the wxc-ex
Install
dsh plugin --profile web add github:bitsmug/dsh-sandbox-mxcREADME
dsh-sandbox-mxc
A DeepSeek Harness sandbox provider backed by MXC (Microsoft eXecution Containers).
It implements the harness's process-confinement seam (ctx.sandbox, a
SandboxProvider from @deepseek-ai/dsh-sandbox) by wrapping the exact argv the caller is about to
spawn:
wxc-exec.exe --config-base64 <policy-json> -- <original argv...>
- No
@microsoft/mxc-sdkimport. The plugin never loads the SDK's native bindings — it only needs thewxc-execexecutable, located withrequire.resolve. So the SDK's “native stdio on Windows requires Node.js 24.21.0 or newer within Node.js 24, or Node.js 26.8.0 or newer” restriction does not apply. It runs on the Node inside the harness (verified onv24.20.0). - One runtime dependency:
@microsoft/mxc-sdk(declared independencies; used only to locatewxc-exec.exe). Config validation is hand-written; no schema library. - No kernel patch. It is a sibling provider to
@deepseek-ai/dsh-sandbox-local, selected through the harness's normal bundle-patch layer. - Policy semantics are not re-implemented here. Read/write roots come from
writableRoots(policy)/canonicalPath(path)exported by@deepseek-ai/dsh-sandbox, so the meaning ofread-only/workspace-writestays in one place.
Requirements
- Windows. This provider drives MXC's Windows backends (
wxc-exec.exe, ProcessContainer / AppContainer). - DeepSeek Harness that mounts the
sandboxservice (@deepseek-ai/dsh-sandbox— declared inpeerDependencies; never bundled). - Node.js
>=22— the harness's own Node is fine. wxc-exec.exe— resolved in this order:config.wxcExecPath→$DSH_MXC_WXC→ the installed@microsoft/mxc-sdk'sbin/<arch>/wxc-exec.exe(arm64/x64), then itsbin/wxc-exec.exe.- MXC
>= 1.0.1for theallowHostLoopbackknob: the ProcessContainer host-loopback fix on PSEC 1.0 (microsoft/mxc#1490) ships in 1.0.1. - An unpackaged PowerShell, when the profile's pwsh tool points at a Store/MSIX build: MXC containers cannot launch packaged (MSIX) apps
(
The target executable '…\WindowsApps\…\pwsh.exe' appears to be a packaged (MSIX) app). Point the executor'spwshPathat an unpackaged build — e.g. the official ZIP extracted to a stable directory of your choosing. - That shell's own install directory must be readable inside the container — list it in
config.readonlyPaths. An unpackaged PowerShell resolves its own path at startup and otherwise fails withFailed to resolve full path of the current executable. - Paths that do not exist on this machine are dropped automatically. Handing one to MXC makes it reject the whole policy
(
DACL fallback requires write-DACL permission on '/tmp'—/tmpis what the shared harness code contributes on Windows), so the provider filters every path list through an existence check before building the policy. - On MXC's
appcontainer-dacltier, the account must be able to write a DACL for every path in the policy. When it cannot — a volume owned by another SID, or a path the container turns out not to be able to read — the provider drops that path and prints why, instead of failing the command, and caches the verdict. It never asks for per-machine ACL surgery. The strongbase-containertier needs no DACL work at all. Which case applies is read from the probe'sneedsDaclAugmentationflag rather than from the tier name, so a tier this plugin has never heard of does not accidentally trigger DACL surgery.
Verified environment
Measured on the development machine; it is a record, not a claim about yours.
| Item | Value |
|---|---|
| OS | Windows (x64) |
| Harness | DSH 0.2.0-rc.2 (the repository source is at 0.2.1-rc.2; this plugin's range covers both) |
| Node.js | v24.20.0 (the harness's own) |
| Shell | PowerShell 7.7.0-preview.5, unpackaged (ZIP-extracted) |
| MXC | @microsoft/mxc-sdk 1.0.1; wxc-exec --probe reported the BaseContainer tier on a Windows 11 host and the AppContainer + DACL tier on a Windows 10 host |
Measured behaviour (the full matrix is under ## Security posture): workspace writes allowed; writes outside the policy denied;
outbound network blocked by default; clipboard isolated from the host. Reads are not an absolute boundary — see the
security-posture notes below before trusting a machine. Three tier-dependent facts are worth knowing:
base-containerneeds Windows 11; Windows 10 can only use the DACL tier. Same binary, two hosts: on Windows 11 (build ≥ 26100)wxc-exec --probereportstier=base-container / needsDaclAugmentation=false / baseContainerApiPresent=true; on Windows 10 it reportstier=appcontainer-dacl / needsDaclAugmentation=true / baseContainerApiPresent=false— the process-security-environment API the strong tier needs is simply not there, so on Windows 10 every path in the policy must be DACL-augmentable (see## Requirements, where the provider handles that by dropping and reporting).wxc-host-prep.exe(elevated) clears the system-drive and NUL-device warnings but cannot create the missing API.
Install
Two things are independent: (a) installing this plugin, and (b) making a wxc-exec
executable reachable.
(a) Install the plugin into a profile
Local checkout (development, and the usual way for a git-only plugin):
# 1. put the repo somewhere stable, then link it into the profile
# <profile>/package.json : "dsh-sandbox-mxc": "link:<absolute-path-to-this-repo>"
# 2. add it to the profile's bundle list:
# "dsh": { "profile": { "bundles": [ "...", "dsh-sandbox-mxc" ] } }
# 3. create the node_modules junction:
# <profile>/node_modules/dsh-sandbox-mxc -> <absolute-path-to-this-repo>
From a registry (if you publish it):
dsh plugin --profile <profile> add dsh-sandbox-mxc
Either way the plugin's own cordis.patch.yml is applied, which
- disables the default provider row (
- id: sandbox→@deepseek-ai/dsh-sandbox-local), and - inserts this plugin — it provides the same service name
sandbox, so every consumer (pwsh,bash, and filesystem tool families) is unchanged.
The Cordis patch dialect treats a truthy
nameas an assertion on an existing row, not a rename — which is why the patch disables the old row and inserts a new one instead of editingnamein place.Restart the profile after changing the patch: the provider lives on the host plane.
(b) Make wxc-exec reachable
In order of precedence:
- plugin
config.wxcExecPath(absolute path towxc-exec.exe); - environment variable
DSH_MXC_WXC; - derivation from an installed
@microsoft/mxc-sdk:<package>/bin/<arch>/wxc-exec.exe(the SDK's own layout —arm64on Arm64 hosts,x64elsewhere), then<package>/bin/wxc-exec.exe(older layout).
If none resolves, the plugin throws while the profile loads (see Host readiness — the same executor
must also pass --probe), and confine() fails closed with SandboxUnavailableError (code
SANDBOX_UNAVAILABLE) rather than letting a command run unconfined. Installing
@microsoft/mxc-sdk is a normal runtime dependency (declared in dependencies, installed from npm);
it is never imported — it is used only to locate wxc-exec.exe.
Configuration
Unknown keys fail the profile load with an actionable error naming the key and the valid set (the plugin is the config boundary: a misspelled or stale key silently dropping your safety intent is worse than a loud failure). The valid keys are exactly the table below.
| Key | Default | Meaning |
|---|---|---|
wxcExecPath | '' | Absolute path to wxc-exec.exe. Empty ⇒ try DSH_MXC_WXC, then derive from @microsoft/mxc-sdk (bin/<arch> then bin). |
readonlyPaths | [] | Extra roots the container may read (beyond the workspace). |
extraWritablePaths | [] | Extra roots the container may write (beyond writableRoots(policy)). |
deniedPaths | [] | Roots the container may never touch (MXC filesystem.deniedPaths). |
denyMustExist | true | Any deniedPaths entry missing on this machine ⇒ refuse that command (MXC rejects a policy containing a missing path). |
network | omitted | Escape hatch, passed through verbatim. Omitted ⇒ MXC's default, which is block (measured default_policy=block). The MXC 1.0 contract accepts only egress and ingress here; the always-supported shape is { egress: { default: 'deny' } }. |
allowHostLoopback | false | Narrow knob over the above: true ⇒ the policy gets network.ingress.hostLoopback: 'allow'. Only host loopback is opened (egress stays blocked). Needed by tools that must start a loopback TCP server (measured case: Simulink inside the container fails with Failed to start server: 127.0.0.1:31540 while loopback is denied). It only fills in / overrides network.ingress.hostLoopback and never touches other network fields; false ⇒ no network key at all. Requires MXC >= 1.0.1 for the PSEC 1.0 fix. |
timeoutMs | 0 | Per-execution timeout in milliseconds; 0 = none. |
containerIdPrefix | 'dsh-mxc' | Prefix for the diagnostic container name. |
sandboxEnv | [] | Extra KEY=VALUE entries for the container's process.env, layered on the backend default (inheritDefaultEnv: true). Entries without = are dropped with a warning (MXC rejects such a command outright). |
injectNodePipeShim | true | Inject NODE_OPTIONS=--import data:text/javascript,… so Node inside the container captures child-process output through file handles instead of named pipes, and fails fast on the async APIs. false leaves process.env / process.inheritDefaultEnv untouched. See Container environment below. |
readinessCheck | true | Run the host-readiness gate while the profile loads (Windows only): resolve the executor and require wxc-exec --probe to select an isolation tier. false skips the gate entirely. See Host readiness. |
probeTimeoutMs | 15000 | Upper bound for one wxc-exec --probe call, in milliseconds. |
validateTimeoutMs | 60000 | Upper bound for the DACL-tier policy self-check command, in milliseconds. |
Both timeout keys must be a finite number >= 1: Node treats timeout: 0 as no timeout, the
opposite of what these fields promise.
- insert:
- id: sandbox-mxc
name: dsh-sandbox-mxc
config:
wxcExecPath: !!js process.env.DSH_MXC_WXC ?? ''
readonlyPaths: []
Host readiness
While the profile loads (Windows only, readinessCheck: true by default) the plugin resolves
wxc-exec and runs that same executable's own host probe, wxc-exec --probe:
- No executor resolvable, or
--probefailing to start (ENOENT, timeout, non-zero exit without JSON), or the probe selecting no isolation tier ⇒ the plugin throws with an actionable error and the profile does not load. A provider that cannot confine would fail every command anyway; a loud failure at load is the honest one. Run"<wxcExecPath>" --probeyourself to see the facts. - Tier-degradation
warningsfrom the probe are reported but never block (MXC explains why it degraded), andallowHostLoopbackis reported when the probe says the host cannot doingressHostLoopbackAllow. - The system drive is checked for AppContainer directory access (
icacls <SystemDrive>\must listS-1-15-2-1/APPLICATION PACKAGES); when missing, the plugin warns with the exact fix — an elevatedwxc-host-prep prepare-system-drive(the command ships in@microsoft/mxc-sdk/bin/<arch>/). Without it, directory listing (e.g.dir) inside the container fails withAccess is denied, while reads and writes still work. Non-fatal.
Set readinessCheck: false to skip the gate; confine() then fails closed on the first command
instead of the profile load.
What the container can see of your invocation
The command, its environment, and the whole policy travel to the executor as a base64 request
envelope on argv (--config-base64). A host user with process-inspection rights can read that
while the process runs, so do not put secrets in command arguments or sandboxEnv values until MXC
provides a non-argv transport (microsoft/mxc#626).
An inline NODE_OPTIONS shim also widens the command line: see Container environment below for the
measured size budget.
Container environment (the Node named-pipe shim)
Inside an MXC container the process token cannot create named pipes — which is exactly how libuv
implements stdio: 'pipe'. Measured in one container on this machine:
| Call inside the container | Result |
|---|---|
spawnSync(cmd.exe, ['/c','echo','hi'], { encoding: 'utf8', timeout: 300 }) | never returns; timeout / killSignal do not fire |
spawnSync(…, { stdio: ['ignore', fd, fd] }) (file handles) | returns in 6–10 ms |
stdio: 'inherit' / 'ignore' | returns in 6–10 ms |
.NET NamedPipeServerStream / net.listen | Access to the path is denied / EADDRINUSE |
So with injectNodePipeShim: true (the default) the provider adds one NODE_OPTIONS entry — an inline
data:text/javascript,… preload, so no file is written — which, inside the container, changes
child_process for every Node process the command starts:
spawnSync/execFileSync/execSync— stdout/stderr are captured through temporary file descriptors and read back; a slot stays a pipe only if the caller asked for something else.execFileSync/execSyncare re-implemented on purpose: Node’s own implementations do not go through the overriddenspawnSyncexport, so overriding the export alone would not cover them. Theexec*Syncerror shape (status/stdout/stderr, message starting withCommand failed:) and Node's defaultencoding: 'buffer'are preserved;maxBufferno longer applies (output goes to a file).spawn/exec/execFile/fork— throw immediately withcode: 'MXC_NAMED_PIPE_UNAVAILABLE'and a message naming the API, instead of hanging forever.
Notes: the shim also governs any Node process the container’s command starts (NODE_OPTIONS is
inherited — that is the point), and a command that genuinely needs asynchronous child processes has to
run outside the container. Set injectNodePipeShim: false to leave the container environment exactly
as it was before this feature.
The shim travels in the command line (environment → config JSON → --config-base64), and Windows
caps a command line at ≈32767 characters — so the inlined source is kept comment-lean, and the test
suite asserts the encoded size (measured: ≈14579 characters for the NODE_OPTIONS entry and ≈20148 for
the whole policy envelope).
Mode mapping
| Harness mode | MXC filesystem |
|---|---|
read-only | readwritePaths: [], and the workspace root is added to readonlyPaths (without that, the container could not even read the workspace) |
workspace-write | readwritePaths: writableRoots(policy) (workspace root + platform temp areas) |
danger-full-access | confine() is not called by the harness |
ui is always pinned to { disable: false, clipboard: 'none', injection: false } and
processContainer.ui to { isolation: 'container', desktopSystemControl: false, systemSettings: 'none', ime: false }.
Security posture (measured, not assumed)
| Property | Measured result |
|---|---|
| Backend | BaseContainer (confirmed from --debug native logs; no --experimental needed) |
| ACL augmentation | --probe reports needsDaclAugmentation: false — it does not need the write-authorization step that the bundled ACL provider requires |
| Enforcement reported to the harness | partial — MXC governs the writes it is asked to govern, but not every file effect (see the filesystem row) |
| Filesystem (writes) | A write outside readwritePaths is denied, on every volume tested (拒绝访问。 from cmd, status 1, no file created); a read-only root cannot be written |
| Filesystem (reads) | Not an absolute boundary. Measured on the base-container tier with MXC 1.0.1: reading a file in the user profile is denied, and enumerating a directory outside the policy is denied — but a plain file read outside the policy on the workspace's own volume succeeded. MXC documents deniedPaths interactions as backend-specific, so treat reads as volume/DACL-dependent rather than granted-or-denied |
| Network | Blocked by default (omitted egress resolves to "deny"; measured default_policy=block) |
| Clipboard | clipboard:'none' is enforced: a host clipboard marker was invisible to the container, and a container marker never reached the host clipboard. Note: enforcement is a silent no-op — it does not raise an error. |
ui.disable | Must be false, otherwise the container blocks Win32k and node.exe / pwsh fail with 0xC0000142 STATUS_DLL_INIT_FAILED |
| Container environment | The container does not inherit the host PATH / environment — configure environment when your commands need a toolchain |
| Screen / desktop | Undetermined. Window enumeration returned 0 inside the container (14 on the host), but “separate desktop” and “synthetic user without enumeration rights” both explain that, so no conclusion is drawn |
Cost of ui.disable: false: Win32k becomes reachable from the container, i.e. the kernel attack
surface is wider than a fully UI-less container — the unavoidable price of running a native Windows
node.exe/pwsh. Clipboard and input injection stay closed, and the harness's bundled ACL provider
does not isolate that surface either, so this is not a regression there.
Denial and runner-failure evidence
confine() returns two pieces of evidence the harness uses to tell "confinement worked and blocked it"
apart from "the runner never ran the command":
denialSignatures— the denial dialect this backend actually produces, matched case-insensitively:Access is denied,Access to the path,Permission denied,Operation not permitted, and拒绝访问. Measured: the harness's normal executor (pwsh/.NET) printsAccess to the path '<x>' is denied.; Node'sEACCES/EPERMshow up as their messages (permission denied/operation not permitted) — the bare errno names never appear in stderr and are deliberately not listed.cmd.exeon a Chinese system prints 「拒绝访问。」 as GBK bytes (BE DC BE F8 B7 C3 CE CA), so the listed Chinese form does not survive a UTF-8 read: acmd-driven denial is reported per the English/errno forms only.runnerFailureRules— five fatal stderr signatures that mean the command never ran:BaseContainer is unavailable,DACL fallback requires write-DACL permission on,appears to be a packaged (MSIX) app,STATUS_DLL_INIT_FAILED,0xC0000142. Deliberately noallowedExitCodesgate:wxc-execexits 1 for its own errors while a confined command may also exit 1, so an exit gate would conflate the two (the harness'sdsh-sandbox-localbwrap/seatbelt rungs match by signature only, for the same reason).
The test suite asserts both with the harness's own predicates (matchesSignature, classifyRunnerFailure):
a denial must not classify as a runner failure, and vice versa.
Tests
node --test "tests/**/*.test.mjs"
Covers: policy→config mapping (including the empty writable set under read-only), the argv shape
and the -- separator, base64 round-trip, the pinned security defaults, fail-closed resolution,
executor candidate order (bin/<arch> before bin), --probe facts parsing, the load-time readiness
gate (success / probe failure / no tier / readinessCheck: false / non-Windows), the DACL-tier
adaptation judgement, allowHostLoopback policy composition, strict config validation, the denial /
runner-failure contract via the harness's own predicates, and the command-line size budget.
License
MIT — see LICENSE.
Related plugins
dsh-infinite-gen-4
minglink/dsh-infinite-gen-4
dsh-purge
mmparko/dsh-purge
cc-safety-net
kenryu42/cc-safety-net
api-relay-audit
toby-bridges/api-relay-audit