@argszero/cordis-plugin-sandbox-grant-advisor
Turns a sandbox environment failure that has no path forward into a
diagnosis the model — and the user reading the transcript — can act on. Seven
signatures, one mechanism:
SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws) # Windows workspace ACL
PTY shell exited during startup # persistent shell × confining mode
[exit code: -1073741502] (0xC0000142) # a confined child that never started
[exit code: 1] sandbox: { mode: "workspace-write", denied: true }
# denied INSIDE the workspace
Windows ACL temp root must be outside the workspace: … # the private temp root is inside the workspace
0x80041003 (WBEM_E_ACCESS_DENIED) in the command's stderr # CIM/WMI refused to the restricted token
sandbox-local windows-acl temp grant materialization failed and its cleanup also failed
# an ACL refusal that withholds its own cause
This plugin is the stopgap for "the error does not name the outstanding
condition". It repairs nothing: no ACL is written, no privilege is requested,
nothing is elevated, no environment variable is set for another process, no
preset is installed and no mode is changed.
The seven failures it recognizes
The first two are recognized on the public tools/post-execute waterfall
(@deepseek-ai/dsh-tools) from the failure text. That seam is the one that has
all three of what a diagnosis needs: the failure
(providers propagate their error unchanged and the tool pipeline settles it as an
isError result), an agent identity to attribute it to (exec.agent), and a
channel that speaks to the model in the same step (PostToolDecision's
additionalContexts, which the agent loop turns into a durable user-role message
— packages/core/agent-loop/src/tool-calls.ts). The third, fourth and sixth are
recognized at the same seam from the canonical value of a result the pipeline
calls a success, for reasons §3, §4 and §6 give in full. The fifth and the
seventh are read from a thrown error's own message like the second; the fifth is
the one family the plugin also reports before anything fails, for reasons §5
gives in full.
That seam, not ctx.sandbox.confine: confine(argv, policy, signal) sees the
confinement failure too, but its signature carries no agent, so a wrapper could
detect the condition and never deliver a word about it to the session that is
stuck.
1. Workspace provisioning — the Windows ACL failure (acl-provisioning)
Twelve reports describe this exact line: [discussion #7538], [discussion #7622],
[discussion #7646], [discussion #7720], [discussion #7750], [discussion #7735],
[discussion #7771], [discussion #7804], [discussion #7816], [discussion #8232],
[discussion #8272] and [discussion #8275] (three of them — [#7750], [#7735],
[#8232] — on data-volume workspaces, where no ACE names the caller at all: the
inherited Authenticated Users: Modify is the whole of their access). In each one
every sandboxed command fails the same way, before it runs, and the error names
neither the missing right nor a remedy.
Two further reports — [discussion #8312] and [discussion #8314] — and a third,
[discussion #8412], describe the other end of the same backend: what its grant
leaves behind after it applies.
The advisory states that too (see What the grant leaves behind),
because the advisory is the thing handing over the command that applies the grant.
#8232 contributes two facts about the shape of the failure rather than its
cause, and both are in the advisory now. One is that the failure belongs to the
workspace, not the command: there a Get-Date failed exactly like anything
that writes, because the grant is materialized before the command runs at all. The
other is the scope of the repair: the (OI)(CI)(WO) line covers this
directory and its children, so a second workspace root on the same volume is a
sibling rather than a child and needs the same line once more — which is why that
reporter saw a second workspace fail on a machine whose first one was already
repaired. The report also reaches the same root cause on its own (the merged write
wanting WRITE_OWNER, which owner-implicit rights do not carry), matching the
backend's documented prerequisite.
#8272 and #8275 add neither a cause nor a remedy — both land on the same
missing right — but each one closes a reading the diagnosis leaves open, and both
are in the advisory since 0.9.0.
#8272 reached the same conclusion by a better probe than the one above: it
set a Low integrity level on a directory it owned, unelevated
(icacls <dir> /setintegritylevel "(OI)(CI)Low"), and was refused. That isolates
the label half of the merged call, which is the half that needs WRITE_OWNER, and
it does so without asking anyone to interpret a merged failure. It is quoted here
as evidence and deliberately not offered as a check: where the caller holds Full
control the same command succeeds, and then the label — and its inheritance — is
already written. A diagnostic that writes when it succeeds is not a diagnostic this
plugin hands out; the two read-only checks above are.
The report then went looking for the backend's own repair
(diagnose-windows-sandbox-acl) on a 0.1.7-rc.2 install and found it missing,
which it read as the package having dropped the skill from its files glob. It has
not: measured on the published tarballs (2026-09-29), 0.1.7-rc.2 names the skill
zero times in either README, ships no assets/ directory at all, and carries no
file containing the registration symbol — while 0.2.0-rc.2 ships
assets/diagnose-windows-sandbox-acl/{SKILL.md,scripts/diagnose-windows-sandbox-acl.ps1}
and names it three times per README. The skill is new in 0.2.0; a 0.1.7
install that found the name was reading a 0.2.0-era document. So the reach that
works is the upgrade, not a packaging fix, and the advisory says so — item 4 below.
#8275 is the other direction: it had the skill, and the skill's own remedy
(Full control) worked, after which it noticed that the Low label the successful
apply writes is what regresses the two side effects it documents. Its proposal is to
degrade provisioning to DACL-only when the label is the half being refused,
which reads as though the label were one of two independent layers. It is not, and
the advisory answers that with the mechanism rather than with a refusal — item 6
below.
#7720 is worth reading for where the failure lands: the grant is materialized
at sandbox initialization, so this is not one refused operation but every
shell tool at once — the reporter could not run netstat or even icacls to
diagnose the error they were staring at (on 0.1.5-rc.3 the same directory
worked, because confinement was skipped silently rather than failing closed).
They also report the two remedies that look right and are not
(takeown /F <dir> /R /D Y, icacls <dir> /reset /T /C), which is why the
advisory names them with the reason each fails instead of leaving the reader to
discover it.
The Windows backend provisions a workspace by writing the directory's DACL and
its mandatory-integrity label in one SetNamedSecurityInfoW call
(packages/sandbox/sandbox-windows-acl/src/acl.ts):
if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`)
Two consequences follow from that one line:
-
The label lives in the SACL, and its half is what gets refused. The
owner's implicit rights cover only READ_CONTROL and WRITE_DAC, so the
combined apply additionally needs WRITE_OWNER on the directory — an
object right, which a Full-control directory (the normal workspace case)
has and a mkdir-created one inheriting "Authenticated Users: Modify"
(0x1301bf) does not. This is the backend's own documented prerequisite
("granted directories must be caller-owned and grant WRITE_OWNER").
-
It is not SeSecurityPrivilege, and elevation is the wrong lever. That
is the token-privilege form of the same idea, and it is the hypothesis the
reports naturally reach for — whoami /priv cannot tell the two apart,
because WRITE_OWNER is an object right and never appears in that table.
Granting WRITE_OWNER on the workspace root needs no elevation. #7735
settles the gate with an isolation table on one machine and one unprivileged
account: the label write succeeds with Full control or with Take-ownership
alone, and fails with ChangePermissions, ReadPermissions or Modify
alone — so the gate is WRITE_OWNER and nothing else.
-
The remedy is the narrowest form of that right. The advisory recommends
icacls <dir> /grant "<user>:(OI)(CI)(WO)" — WO is WRITE_OWNER, i.e.
literally the right the documented prerequisite names, so the one-liner grants
nothing the harness did not ask for — and offers Full control second, as the
same line with F in place of (WO). Both assume the caller owns the
directory: owner-implicit rights cover the DACL half of the merged write.
-
The version boundary is stated, because the error cannot carry it. Up to
0.1.6-alpha.x the backend's SetNamedSecurityInfoW wrote the DACL only
(flag 4) and a Modify-only workspace provisioned fine; the mandatory label —
and with it the SACL, flag 20 — arrives in 0.1.7-alpha.1. So the same string
on an older line belongs to a different cause space, and the natural reach
(downgrade to the build that "worked") is refused with its reason: the label is
what confines deletes to the workspace, and reverting it reintroduces the
escape it closed. #7750 asks for exactly this and explains why it is a
usability regression traded for a security fix.
The grant is materialized lazily, on the first confined call, and nothing is
cached when it throws — so the same failure repeats per command (850 calls
across 39 sessions in #7622; 52,588 output tokens with no output in #7538),
which is why the loop cannot separate it from ordinary command noise.
On the first recognized failure, the result is enriched with:
Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.
What was reported:
SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
Why it is refused while the directory looks writable: that call is a MERGED write ...
... the label half additionally needs WRITE_OWNER on the directory. ...
A version boundary worth knowing before reaching for an older build: the label half is new to this package.
Up to `0.1.6-alpha.x` the backend touched the DACL only (flag 4) ... `0.1.7-alpha.1` is where the mandatory
label — and with it the SACL, flag 20 — arrives. ... Rolling back is not the fix either: the label is what
confines deletes to the workspace, and reverting it reintroduces the escape it closed.
The other boundary on that last line: the `diagnose-windows-sandbox-acl` skill is not part of `0.1.7-*` at all
— it arrives with `0.2.0`, where the backend starts shipping it under `assets/`. The reach that works is the
upgrade, not a packaging fix.
Confirm the cause (unelevated) — `icacls` is a normal user command:
icacls "D:\ws"
Ownership decides which of the two commands below can work, so read it first — PowerShell 5.1 or later:
(Get-Acl "D:\ws").Owner # compare with: whoami
IF YOU OWN THE DIRECTORY — the usual workspace, on a data volume as much as on C::
one unelevated line, then run the command again:
PowerShell: icacls "D:\ws" /grant "$env:USERNAME:(OI)(CI)(WO)"
cmd: icacls "D:\ws" /grant "%USERNAME%:(OI)(CI)(WO)"
... Full control works just as well — the same line with `F` in place of `(WO)`
IF YOU DO NOT OWN IT — a directory an installer or another account created, e.g. owner
`BUILTIN\Administrators`:
the line above cannot run at all. Changing a DACL takes WRITE_DAC, which you hold neither as owner nor
through any ACE, so `icacls /grant` is refused with `Access is denied` — for the very command that would
fix it. ... Run the grant once from an account that already holds both — that is, from an ELEVATED prompt:
icacls "D:\ws" /grant "<your-account>:(OI)(CI)F"
... or take ownership first (also elevated; it wants SeTakeOwnership), after which the unelevated `(WO)`
line above applies: icacls "D:\ws" /setowner "<your-account>"
... or sidestep the ACL: create the workspace under `%USERPROFILE%`.
What will NOT fix it on its own — both look like the right move, and both were tried and reported:
takeown /F "D:\ws" /R /D Y
makes you the owner, and ownership's implicit rights are READ_CONTROL and WRITE_DAC only — so it
supplies the DACL half and still not WRITE_OWNER, the right this call needs.
icacls "D:\ws" /reset /T /C
restores inheritance, and inheritance is what supplied the Modify-only ACE above. Where it strips the last
entry naming you, the directory ends up in exactly the state the diagnosis above describes — its only access
the inherited `Authenticated Users:(M)` (#8314 measured that follow-on failure).
What the grant leaves behind, once it applies — worth knowing before you run the command above, because the
backend does not take it back:
The three entries are STANDING, deliberately, and nothing revokes them. ... They outlive the session and the
harness exiting (`sandbox-windows-acl/src/grant.ts`, `src/index.ts`).
The Low integrity label is INHERITABLE (`(OI|CI)`) and it lives in the SACL — which is why the `icacls /reset`
above does not take it off: that command rebuilds the DACL. Windows starts a process at the minimum of the
user's and the program's integrity, so anything started from a tree the harness has written to runs at LOW
integrity, and none of the symptoms names DSH (#8312 collects them): ... (#7709), ... (#8175), ... (#7735).
It can also leave the workspace. An NTFS hard link is a SECOND NAME for one file object, so both names share
one security descriptor — and a pnpm workspace is largely hard links (`node_modules` pointing into a
content-addressed store on the same volume). ... `vite build` unable to remove its own temp file, `pnpm
install` unable to replace a hook (#8314 measured the whole chain). ...
It is also why the label is not removable by the commands above: removing an integrity label needs
`WRITE_OWNER`, and the maintainers' own `diagnose-windows-sandbox-acl` skill reports it without removing it.
A procedure that does remove it exists, and the advisory quotes it as its reporter's rather than this
project's — with the boundaries that come with it ([#7846]; see the section below).
None of this makes the command above the wrong move — without it, nothing sandboxed runs in this workspace. It
is what the harness does to a directory it has been pointed at, and it is worth knowing before rather than
discovering it as a broken build in some other project later.
One thing to know before asking for a weaker grant, because that is the next idea after this diagnosis —
and it is not a smaller version of the same grant:
The label rides the SAME `SetNamedSecurityInfoW` as the DACL, so there is no DACL-only path to fall back
to — it would have to be built. And dropping the label alone would not leave a working workspace: the
backend lowers the confined token to Low before any child starts, and the directory's Low label is what
lets that Low child write here at all. Declining the label usefully means declining the token's Low level
with it, which gives up half the confinement rather than one of two independent layers.
Why the remedy forks (added in 0.5.0). The same error covers two different
rights situations, and one command cannot serve both. Where the caller owns
the directory, the owner's implicit WRITE_DAC satisfies the DACL half of the
merged write, WRITE_OWNER is the single missing right, and the unelevated
icacls /grant that supplies it can itself run — [#7750] measured exactly that
fix working. Where the caller does not own it ([#7771]: owner
BUILTIN\Administrators, held deny-only for that token), WRITE_DAC is missing
too, so the very same command is refused before it does anything, and (WO)
alone would not be enough even if it went through. The failure text is identical
in both, so the classifier cannot pick a branch — the advisory hands over the
ownership check as the selector instead of guessing, which is also the
actionable-guidance half of what [#7771] asked for. Until 0.5.0 a single
unconditional one-liner was printed, with a sentence noting it assumed
ownership; that would have sent the second environment to a command that is
denied — the same defect this plugin exists to answer.
What the grant leaves behind (added in 0.10.0; the recovery quoted as its reporter's since 0.21.0)
[Discussion #8312] and [discussion #8314] report the other half of this backend,
and the advisory now states it — before the reader runs the command it is being
handed, because that command is what makes the backend's grant apply:
- The three entries are standing, by design, and nothing revokes them. The
workspace grant is a reuse cache: the backend's dispose path revokes the
revocable (temp) grants and leaves the workspace edits, and its fail-closed
cleanup says the same in plainer words — standing ACEs "are NOT revoked — they
are the intended end state (the reuse cache), not an error artifact"
(
src/grant.ts, src/index.ts). They outlive the session and the harness
exiting. ([#8312] arrived at this from the README's own description of the
cache; the source is quoted in the advisory.)
- The Low integrity label is inheritable, and it lives in the SACL. That is
why
icacls /reset — already listed as a non-fix for a different reason — does
not remove it: the command rebuilds the DACL. Windows starts a process at
min(user, image) integrity, so anything started from a tree the harness has
written to runs at Low integrity, and none of the symptoms points at DSH:
an Electron/Chromium app exiting 0x80000003 with no output ([#7709]),
msbuild / dotnet / npm refusing or warning about the files as if they came from
the Internet when no Zone.Identifier exists ([#8175]), a double-clicked
.exe / .cmd reporting "publisher could not be verified" ([#7735]).
- It can leave the workspace. An NTFS hard link is a second name for one
file object, so both names share one security descriptor — and a pnpm workspace
is largely hard links (
node_modules pointing into a content-addressed store
on the same volume). The inheritable label therefore lands on the store's
objects and stays there, after which every project building from that store
gets executables that start at Low integrity and failures that name the build
tool ([#8314] measured the whole chain: vite build unable to remove its own
temp file, pnpm install unable to replace a hook). The backend's own suite
pins the reach as a known boundary — "a workspace hard link lets the grant
reach an external file object" (tests/runner.spec.ts) — and its README calls
refusing multiply-linked files unviable for ordinary pnpm installs, which
leaves the out-of-tree reach open rather than unknown.
- A removal procedure exists, and it is a user's rather than ours. The
maintainers' own
diagnose-windows-sandbox-acl skill reports LOW_LABEL and
by design does not remove it, and removing an integrity label needs
WRITE_OWNER — the same right this whole failure is about. But [#7846]'s
reporter measured one end to end on a real tree, and the advisory hands it over
as theirs, with its boundaries: back the ACLs up (),
drop the world deny with , remove the
capability ACE through the ACL API — cannot name an SID at
all and answers (1332), so a PowerShell
/ / is the only route — and relabel with
(85 613 objects, no failures). What it
leaves behind is an , not the absence of
one; the relabel visits the whole tree; and the label edit needs
per object. This project still verifies no line of it on a Windows host, which
is why it is quoted rather than shipped as our own command — the same standard
the hard-link reach above is held to.
The section is emitted for every ACL class, like the version boundary and for
the same reason: it is a fact about the package's grant, and that grant is the
remedy the advisory hands over in all three classes. It also closes with what the
fact does not mean — the command is still the right move, because without it
nothing sandboxed runs at all.
What is deliberately not shipped: icacls ... /grant "<user>:(OI)(CI)(WD,WO)",
the two needed rights named explicitly. It is the tighter form and it is
plausibly correct syntax, but this project has no Windows host to run it on, and
shipping an unverified command in a remedy whose whole point is that it works is
the failure mode being fixed. F (verified by [#7804]'s reporter) and
/setowner (named by both reports) are given instead.
2. Persistent shell startup (pty-startup)
[Discussion #7638] reports the second shape: with the minimal preset on
Windows, and under a confining sandbox mode (workspace-write / read-only,
not danger-full-access), every shell call dies instantly with
PTY shell exited during startup
The terminal backend spawns the shell through the sandbox
(packages/terminal/terminal-bash/src/index.ts; the throw is in
src/session.ts and src/index.ts, both on the same waitReason === 'session_exit' branch), and there the pseudo-console cannot be created at all,
so the child exits before its first prompt. Retrying never helps; the message
points at no cause.
The reporter's own three-arm control makes the sandbox mode the discriminator:
minimal × confining fails, minimal × danger-full-access succeeds, standard
(one-shot shell) × confining succeeds. That is why the advisory is only ever
built with the resolved mode the failing call actually ran under — from
ctx.sandboxPolicy.resolve({ session }), the same resolver the terminal layer
calls before spawning, with the same session.
Inside a confining mode, the host binary decides (added in 0.10.0).
[Discussion #8322] ran the control one level deeper — same runner, same ConPTY,
every arm — and separated what the mode alone does not: with the runner hosted by
a plain console-subsystem node.exe, the confined shell starts and its prompt and
shell-integration marks are correct; with the runner hosted by the packaged
desktop's GUI-subsystem Electron executable, the child dies silently — zero
bytes on stdout and stderr, and the non-interactive arm exits 0 with everything
it printed lost. It is the same rule the 0xC0000142 family states for its own
case: under the restricted token a console can be inherited but not created,
so the binary that owns one (or owns none) is what the arms turn on. That is also
why the same version behaves differently depending on how it was started: the
desktop app fails where the Web UI launched from a terminal — whose
process.execPath is a real node.exe — is reported working under the same
confining mode ([#8313], whose sibling report is the 0xC0000142 shape of the
same host difference). The advisory therefore names the host alongside the mode,
says the outcome is deterministic per (session mode × host) rather than
intermittent, and adds that the mode which counts is the one the session
records, not the one the environment now holds — a session that recorded the
confining mode keeps failing after the app is restarted with another mode in its
environment, while switching it inside the session takes effect at once.
[Discussion #9170] (added in 0.16.0) supplies that host split from the user's
own side: on one machine the identical confined command works under dsh web
and fails under the packaged dsh desktop, with the mode and the command held
fixed — the comparison the three most recent 0xC0000142 reports could not make,
because those machines only ever ran one app. It also corrects the conclusion
that comparison invites: switching the session to read-only is not a
reliable way out, because it goes through the same restricted runner started
from the same host binary — it changes what is granted, not who spawns the
child — and the one report where read-only succeeded while workspace-write
died was driven through the sandbox API from a real node host, not from the
packaged desktop app. Worth one try, not worth counting on; a host that owns a
console remains the first move.
The advisory that follows is addressed to two different readers:
Persistent shell failed to start — command execution is unavailable in this session, and retrying cannot fix it.
What was reported:
PTY shell exited during startup
The `bash` tool is a PERSISTENT PTY session (a shell that stays alive between calls), and this session's sandbox mode is
`workspace-write` — not `danger-full-access`. A confining mode spawns the shell through the sandbox, and there the
terminal backend cannot create the pseudo-console at all, so the child exits before its first prompt. ...
Which sessions fail inside that combination is not chance — it is deterministic per (session mode × the host
binary carrying the sandbox runner) ... Measured against the desktop build with the same runner and the same
ConPTY in every arm (#8322):
- runner hosted by a plain console-subsystem `node.exe` → the confined shell starts ...;
- runner hosted by the packaged desktop's GUI-subsystem Electron executable ... → the child dies silently ...
The rule behind both this and the `0xC0000142` family ... is the one stated there for its own case: under the
restricted token a console can be INHERITED but not CREATED ... And the mode that decides is the one the SESSION
records, not the one the environment now holds ...
Do NOT retry, and do not look for a command that fixes it: every attempt will fail identically, and there is no
shell to run a command in. Use your file read/write tools instead, and hand the choice below to the user.
What unblocks the session — the user's decision, not the model's:
1. switch the agent preset to `standard`, whose shell tool is a one-shot subprocess (no PTY) and works
under the sandbox; or
2. override the `preset-minimal` row in your profile patch — `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, or
`$DSH_HOME/cordis.patch.yml` for every profile — replacing its `persistent-shell` group with
`@deepseek-ai/dsh-tool-pwsh` (a one-shot subprocess, no PTY); the patch layer is yours, so an upgrade
will not overwrite it; or
3. run the session from a host that owns a console instead of the packaged desktop app — the Web UI started
from a terminal (`process.execPath` is a real `node.exe` there) was reported working under the same
confining mode and the same version (#8313); or
4. run the session with `danger-full-access`, which drops the very confinement the sandbox exists to give.
Prefer 1 to 3.
The model's instruction is to stop — not to run a command (there is no shell
to run it in) and not to call a fallback shell tool (minimal mounts exactly
one platform-selected persistent shell and no one-shot shell, by design:
.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md).
Naming a tool the failing composition does not mount would be a wrong remedy,
which is the main risk this family's text is written to avoid.
The remedy is a patch layer, not a directory. The pre-declarative
$DSH_HOME/.agent-presets/<id>/ preset folder is a plausible-looking trap: it
still reads as the natural place to put a preset, and nothing in the harness
reads it any more (@deepseek-ai/dsh-agent-preset-registry: the registry
"neither scans directories nor accepts preset paths"). Preset changes are
@deepseek-ai/dsh-agent-preset rows — an insert for a new one, a patch keyed
by row id (preset-minimal) for a change to a shipped one. A test arm asserts
the advisory never names the dead directory.
3. A confined child that never started (native-init)
Thirteen reports of one exit code: [#7876] and [#8193] (the packaged desktop
app), [#8313] (the same version and the same mode run as the desktop app versus
the Web UI launched from a terminal, which works), [#8334] (the same code with
the authorization side attached: the grant succeeded and every confined child
still died), [#7877] (MSYS2 / Git
Bash), [#8990] and [#8991] (the same code on two more Windows builds, with no
dependence on the PowerShell version or the install layout, and — for those
machines — no way to tell which producer it was) and [#9186], which isolated it
to a single input — and [#8208], which found the mechanism the console cases
share, [#8336], which measured the creation flags that mechanism turns on,
[#9238], which isolated the mechanism's input by varying only the runner's
console topology, [#9336], which filed the combination a bare mode switch
mis-attributes and the two costs that go with it, and [#9467], which turned the
third producer's input into a rule — measured on Windows 10 19044, so the build
number is not the variable. All are 0xC0000142
STATUS_DLL_INIT_FAILED — the Windows
loader terminated the process while it was initializing its native images, i.e.
before the program's entry point. A command that ran and then failed exits
with its own status and prints its own output; this one produced neither.
Two reports filed in the same wave are scope, not members, and the advisory
names them as such rather than folding them in: [#9463] is the same backend
stalling before any child is spawned at all (no approval/request event, no
process), and [#9471] is a child that does start and then fails to load a
library at runtime, dying with E_ACCESSDENIED (0x80070005) and a message of its
own instead of this bare status. Neither carries 0xC0000142.
| what was run | what came out | exit code |
|---|
cmd.exe /c "echo cmd-ok" | cmd-ok | 0 |
pwsh -NoLogo -NoProfile -Command "Write-Output pwsh-ok" | pwsh-ok | 0 |
D:\Git\bin\bash.exe -c "echo bash-ok" | couldn't create signal pipe, Win32 error 5 | -1073741502 |
Three producers have been measured under a confining mode:
-
An MSYS2 / Git-Bash program ([#7877]). The restricted token's runtime
cannot create the pipe it uses for signals, so bash aborts in the loader
phase, while cmd.exe and pwsh run fine in the same workspace under the
same mode. The plugin's own composition has no way around this: tool-bash
and bash-sandbox are disabled on win32
(@deepseek-ai/dsh-base/cordis.patch.yml), so the combination is likely
never covered upstream. The one conversion a model can make itself is to
write the same work as a PowerShell or cmd command.
-
The packaged desktop app's sandbox runner ([#8193], [#8208]).
dsh-sandbox-local launches the runner as [process.execPath, entry], and
in the packaged build process.execPath is the Electron executable. The
mechanism is the runner's console, not its token ([#8208]): the confined
child inherits a console from the runner, and a runner that owns none leaves
the child to ask for one of its own — which a restricted token may not have.
The capture is three lines: conhost.exe is created by the restricted child,
exits 0xC0000022 STATUS_ACCESS_DENIED, and the child then dies with
0xC0000142. Two console-less configurations are measured, and they share that
mechanism: (a) the host binary is a GUI-subsystem program, which never owns
a console — the packaged desktop, where every confined command dies this way
([#8193]); (b) the host binary is a real console-subsystem node.exe and
the runner was still spawned without a console, because DETACHED_PROCESS was
set — spawnSync(node, [runner, …], { detached: true }) returns 0xC0000142
where the identical call without that flag returns 0 ([#8208]). Shape (b) is
the one worth handing over, because it needs no desktop and no particular
machine. What is not the discriminator is the token: [#8208] compared
whoami /groups and /priv from children of a working node host and of the
failing Electron host and found them identical, and a low-integrity cmd.exe
runs fine on that machine. The plugin reports whether this process is an
Electron binary (process.versions.electron) as a measured fact rather than
assuming it.
Why this family is read from a successful result. The producer never marks
it an error, and that is a fact about upstream rather than a choice here:
RUNNER_FAILURE_RULES['windows-acl'] admits exactly one code —
[{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]
(packages/sandbox/sandbox-local/src/index.ts) — and classifyRunnerFailure
skips any other code before it looks at stderr
(packages/sandbox/sandbox/src/diagnostics.ts), so 0xC0000142 is never a
runner failure and SandboxUnavailableError is never thrown. The renderer then
reports it the way it reports any finished command — "Non-zero exits are
reported, not errored … only infrastructure failures (spawn errors, aborts)
surface as isError results" (packages/shell/tool-pwsh/src/render.ts) — as
[exit code: …]. Every version of this plugin before 0.6.0 read error results
only and was structurally blind to it, which is exactly why the model retries a
command that can never start.
The read is ToolExecutionSuccess.value — the tool's own canonical output,
documented as "Execution-local canonical value; deliberately omitted from
durable events" and not carried on failure results at all — so the code
arrives structurally rather than as a line of text. A command that prints
[exit code: -1073741502] is not this failure, and neither is a value some other
tool happens to build with an exitCode field: the classifier requires the
shipped shell projection (kind: 'foreground' plus an integer exitCode).
The advisory that follows:
Sandboxed command never started — the process died while its native libraries were loading.
What was reported:
[exit code: -1073741502] (0xC0000142 STATUS_DLL_INIT_FAILED)
The call ran under sandbox mode `workspace-write`, where the harness starts every command through its
restricted-token runner.
0xC0000142 is STATUS_DLL_INIT_FAILED: ... this is BEFORE the program's entry point. ...
Nothing in the code says "sandbox" by itself; what makes the sandbox a candidate is the mode above ...
Three producers have been measured under a confining Windows mode. Check which one this is:
1. An MSYS2 / Git-Bash program ... (#7877)
If that is what could not start: write the same work as a PowerShell or `cmd` command instead
2. The packaged desktop application's sandbox runner ... (#8193, #8208)
This process is NOT an Electron binary (`process.versions.electron` is unset), so producer 2 does not apply here.
3. A capability SID inside the restricted token's own restricting list ... (#9186)
CHECK, in TWO STEPS — the first alone does not settle it. Step 1: ... change only the MODE ...
Step 2: establish whether the runner OWNS A CONSOLE, because producer 2 fails the very same switch ...
Since 0.20.0 there WAS a RULE for which input does it, and 0.24.0 WITHDRAWS it: its own reporter
retracted that conclusion in #9467 before the publish, and what replaces it is the trustee of the ACE
the default-DACL call merges, not the restricting list's composition. Three parts must hold: the SID
the merged ACE names must be an ENABLED GROUP of the token; that SID must ALSO be carried in the
restricting list; and the list must STILL carry the logon SID and EVERYONE. The one-part form this
copy used to state alone — the absence of a trustee the token itself holds — is necessary and NOT
sufficient.
The write SIDs are DERIVED FROM A PATH, and a path-derived SID is never an enabled group of any token,
so the shipped workspace-write call names a trustee that fails the FIRST part whenever a temp directory
exists. That is why no setting repairs it: recovering means naming an enabled group in that call, which
is a change to the backend.
A second input sits on the SAME token and is NOT the restricting list: the token's DEFAULT DACL ...
since 0.23.0 it is MEASURED rather than a candidate — which SID that call names is the input ...
Do not retry this call: the environment has not changed, and the identical call produces the identical code.
A `read-only` session is NOT a substitute for a working `workspace-write` one — the mode switch above is a
diagnostic, not a repair. ... under `read-only`, PowerShell ... starts in ConstrainedLanguage ...
Honest boundary — 0xC0000142 has producers this list does not have: a program that cannot load one of
its own DLLs dies this way too, and the sandbox backend's own source records that a child started with
a hidden console window does as well ... This is not a claim that the sandbox caused the failure.
What it does not claim. The code is a loader status, and the loader reports
the same status for causes that have nothing to do with the sandbox (a missing
DLL, a program's own initialization failure, the hidden-console child the
backend's own source avoids CREATE_NO_WINDOW for). So the advisory diagnoses
the class ("the process never reached its entry point") and enumerates the
producers measured under a confining mode, each with the check that separates
them — one of which the reader answers (what program failed to start) and one of
which the plugin answers (is this host the packaged desktop binary). Where a
producer has more than one measured shape, the advisory names all of them and
records which check separates them outside the session, rather than asserting
the single shape that happened to be measured first. It never
offers danger-full-access as a fix and never suggests a sandbox setting be
relaxed.
4. Denied inside the workspace (workspace-denial, added in 0.11.0; the branches it forks into in 0.13.0 and 0.14.0, the breadth of the second in 0.22.0)
[#423] is one report and its own follow-up, and it is the other end of the
backend §1 is about. There the workspace grant could not be applied at all and
every command died before it ran; here the grant was applied — on the
workspace root, once — and part of the tree still refuses writes, forever. The
report's shape: under workspace-write on Windows, a command writing into a
subdirectory that was created or moved in from outside the session (an
installer, an editor, another harness running under its own account) is denied,
while the same command against a directory the harness itself created succeeds.
[exit code: 1] sandbox: { mode: "workspace-write", denied: true }
The signature is a value, not a message, and this family is invisible from
the error path for the same structural reason §3 is: a denied command exits
nonzero, and the shipped shell tools report a nonzero exit as a finished run
rather than as isError
(packages/shell/tool-pwsh/src/render.ts reports [exit code: N] and drops a
denial marker). The fact therefore arrives in ToolExecutionSuccess.value,
where tool-bash / tool-pwsh project what the sandbox executor stamped
(packages/shell/bash-sandbox/src/index.ts, pwsh-sandbox): the mode the call
actually ran under, whether the backend's own refusal dialect appears in the
captured stderr, and the enforcement that applied. Reading the executors'
own stamp rather than a sentence means this plugin is reporting the harness's
reading of its own sandbox, not a guess about a line of output.
What the advisory says. Four things, and one it refuses:
- That retrying is provably useless. The host-side grant is written once, on
the workspace root, and relies on Windows ACE inheritance to reach the
tree beneath it. Writing an inherited ACE into an already-existing child
needs
WRITE_DAC on that child; where the caller does not hold it, Windows
skips the child silently — no error, no return value, no log line. The backend
then checks only the root (hasExactGrant(workspaceRoot) in
packages/sandbox/sandbox-windows-acl/src/acl.ts) and returns early once the
grant is there, which it is from the first call onwards. The descendants that
missed the propagation are never revisited — not later in this session, not in
any later one.
- Which objects miss it, and how many. It is a fact about who created
them: objects the harness creates inherit the ACE, and objects that already
existed do not. [#423] measured 170 of 729 objects missing it, including
root-level files — so "write at the workspace root instead" is not a safe
move either.
- The discriminator, and its second half. The advisory prints the path it
keyed on beside the root it tested it against, so the reader can audit the
claim instead of taking a statement about two strings on faith. Then the
measurement a single check gets wrong: reading and listing use the normal
token while writing and deleting use the restricted (low-integrity) one,
and both sides must pass — so an object whose DACL names only
Administrators/SYSTEM plus the capability SID is refused on the read side
too, and looks fine to a check that merely greps for the capability SID.
[#423] measured exactly that on a .cache directory.
- The fork, and the other two branches (the second added in 0.13.0 from
[#8383] and its second instance [#8421]; the third in 0.14.0 from [#8409]). The
grant and the mandatory-integrity label go out in the same single
security-descriptor write, and that write lands on the workspace root (plus
the session's private temp directory) with no descendant walk — so the
label half can fail to reach the tree while the DACL half arrives at every
level, and it produces the identical denial. The backend's root-only
idempotency check then requires the grant, the world delete-child deny and
the exact label (
hasExactGrant() + hasExactDeny() + hasExactLabel() all
matching, acl.ts:386-388, label read at :198-204) before it returns early,
so once the root is labelled the propagation is never attempted again and an
unlabelled child is never revisited — the same root-only short-circuit as the
DACL half, on the other half of the same call. From inside a session the
branches are indistinguishable, so the advisory forks them on , the
one fact the reader already owns: a of stubborn objects while the
rest of the tree writes normally is the DACL branch, with the root itself the only writable place is the label
branch, and — nothing writable, not even the root —
is a third branch with a different cause and a different remedy (below).
Breadth is the (widened in 0.22.0 from [#9559]): the
second of those breadths covers shapes, and one read of a child
separates them. The child may carry the capability ACE with (the label branch above), or it may carry — no ACE and no
label — in which case the whole security-descriptor write missed it rather than
one half of it. The report measured the second on a : every write below the root denied, a write the root succeeding,
and the child holding nothing at all. So whenever the root itself still writes,
the advisory tells the reader to ask the read rather than trust the breadth. A
Low-integrity child may write to a directory only if that directory's own label
is Low — the kernel's no-write-up check runs the access check
— so a DACL that is perfect everywhere changes nothing. [#8383] measured it on
with the decisive control of a directory created the
grant: it inherits the capability ACE marked and still gets no label.
(The report's own care is worth keeping: a directory having no
label proves nothing, because inheritance is per-parent and the intermediate
directory carries none — only a child of the labelled root is
evidence.) From outside a session the repository's own diagnosis skill
separates the DACL half from the label half (,
0.2.0 and later, prints the owner, the caller's rights and
/ for the one and — — for the
other). And a widened DACL does not clear the label half either, because the
refusal precedes the ACL: [#8383] confirmed that adding an explicit
entry for the user on a subdirectory still left the write denied.
What it refuses to explain, and why that is the design. A denial is the
sanctioned outcome in three other situations, and advising about a missing
inherited grant in any of them would be a confidently wrong cause:
- Outside the workspace — a confining sandbox denying a path outside its
writable roots is the whole point, and the denial surface's one-shot escalation
offer is correct there.
- Under
read-only — that mode denies every write by construction, so an
in-workspace denial under it is the mode working.
- A runner failure — the executor refuses to call a run denied when the
runner itself failed, and such a value is left alone for the same reason.
What is left is exactly the anomaly: a denial under workspace-write of a path
inside the session's own workspace. That is why the family reads the mode off
the value (the executor stamped the mode it actually ran under, so no policy
lookup can disagree with it), tests containment against the root the policy
resolver reports, and speaks only when every absolute path the command's own
arguments name lies strictly below that root (narrowed in 0.22.0). A command
that names an outside path as
well — an interpreter under C:\Program Files, an output directory on another
volume — is refused rather than guessed at, and so is a command naming only
relative paths: in both cases the plugin cannot say which path was denied, and
silence is the fail-closed direction. The same standard applies to a command
naming the root itself: the family's account is about a descendant that
missed an inherited ACE, so a denial naming the root is the third branch's fact
(the harness's own standing grant gone from the root's DACL) and the failure text
declines it rather than lending it the descendant's explanation. Widening
recognition is cheap; widening a claim past the mechanism it rests on is this
plugin's whole subject. The platform gate is real here and cannot
be dropped: the mechanism is ACE inheritance, which no other backend has.
Where the defect is. In the harness backend, not in the session's choices:
the grant and the label go out in one security-descriptor write on the root,
in the expectation that Windows inherits them downward, and the objects that
missed them are never revisited because the idempotency check that would notice
returns early on the root. Nothing reachable from inside a session repairs that.
This plugin only diagnoses it — it edits no ACL, elevates nothing, and
repairs nothing.
On a denial it cannot finish placing — a root the policy resolver does not
report, or no mounted policy service at all — the plugin withholds the advisory
and says so once on the host log. That is the disclosure rule §1's mode gate
follows as well: silence alone would make "the sandbox is not the cause"
indistinguishable from "this plugin could not tell".
[#423] is a Discussion (the repository has issues disabled), and the reply
covering this family is posted there.
5. The private temp root inside the workspace (temp-root-inside-workspace, added in 0.15.0)
[Discussion #9175] reports the one Windows state in which the sandbox cannot start
any process at all: a session opened on a workspace that contains the
system temp directory. On Windows the per-user temp root is
C:\Users\<you>\AppData\Local\Temp, so a session whose workspace is the profile
directory — or any ancestor of it — is in that state, and under workspace-write
every command fails, echo test included, with no subprocess output and an error
that names neither operand to move nor the lever that moves it. The file tools
keep working, because they never spawn the sandbox runner; only command execution
is gone.
The invariant is assertTempRootOutsideWorkspace(workspaceRoot, tempRoot)
(packages/sandbox/sandbox-windows-acl/src/path-boundary.ts), and its own comment
gives the reason: a workspace-write session is handed two capabilities that must
not contain one another — a standing write grant on the workspace root and a
revocable private temp capability under a random directory created beneath the
temp root — and "every child created below it would inherit the standing workspace
capability", which would make the revocable half permanent. So the backend refuses
instead of materializing it. The two directories are compared canonically
(realpathSync.native on both, then path.relative), which is why this is a
filesystem fact rather than a spelling.
One sentence, two producers. The same assertion is thrown twice on the way in,
and a fix has to satisfy both:
- the session-scoped provider (
sandbox-local), while it assembles the runner
argv and before any child exists — which is why the call reports no subprocess
output at all;
- the runner (
sandbox-windows-acl), which asserts the same pair again for
workspace-write before it spawns, writing its line to stderr behind the
windows-acl-run: marker with exit 127 — the signature the seam reads as a
runner failure, so that copy reaches the session as the Runner failure:
detail of a SandboxUnavailableError rather than as a library assertion.
The family recognizes both spellings and names which carrier refused, because
the two fire at different moments and only one of them has anything on stderr.
It is read from the error, never from text that merely mentions it. The
signature is the assertion's own sentence with its two operands, and it is read
from error.message alone — not from a result's rendered content. A command whose
own output quotes the sentence is an ordinary successful run and is left alone
(this suite pins that arm), which is the same discipline §2's whole-line PTY match
follows.
Why it ships no repair command. There is nothing to repair: the refusal
happens while the sandbox is deciding whether it can set up its own capabilities,
before any process is created, and no icacls grant, no elevation and no
ownership change affects it. What moves the condition is the environment — the
temp root or the workspace — and it is the user's move, because the failure it
explains took command execution away and there is no shell left in the session to
run a remedy in:
- Point the harness's temp directory outside the workspace before it starts.
os.tmpdir() on Windows is GetTempPathW, which reads %TMP%, then %TEMP%,
then %USERPROFILE% — so set TMP=C:\dsh-temp (with the directory created
first, since the check resolves both paths) in the process that launches the
harness is enough.
- Or open the session on a workspace that is not an ancestor of the temp root —
C:\work\project rather than C:\Users\<you>.
read-only is deliberately unaffected (no private temp capability is
materialized, so the assertion is never reached), and danger-full-access never
spawns through the sandbox at all — passing under it is consistency, not a
workaround. The advisory says so, because both are the next ideas the diagnosis
invites.
The pre-flight half. This is the plugin's only prediction, and the family's
second seat: on Windows, before the first tracked call, it asks the public
ctx.sandboxPolicy.resolve({ session }) for the session's mode and workspace root
and compares the root against os.tmpdir() with the backend's own canonical
computation. A workspace-write session in the condition is told before its
first command fails — the model gets the environment change without first spending
a turn on an echo that cannot run. The two seats are separate on purpose: the
pre-flight reports the standing condition, and if a refusal does happen, the
producer's own line and the carrier that wrote it are facts only the failure half
can have, so a second advisory follows. The pre-flight prints the three facts it
computed (host, mode, both directories) so the claim can be audited, and states the
one verification available when no command can be run — if a command does run in
this session, this condition does not apply and the message can be ignored. A
Windows host that cannot answer at all (no policy service, no session, a resolver
that throws) is disclosed once on the host log rather than passed off as clear,
which is the disclosure rule every other family keeps.
[#9175] is a Discussion (the repository has issues disabled), and the reply
covering this family is posted there.
6. The CIM/WMI boundary of the restricted token (cim-wmi-denial, added in 0.17.0)
[discussion #9272] reports a boundary rather than a bug: under a confining Windows
mode every WMI query, and every cmdlet built on one, is refused with
Get-CimInstance : Access is denied
+ CategoryInfo : PermissionDenied: (root\cimv2:Win32_OperatingSystem) [Get-CimInstance], CimException
+ FullyQualifiedErrorId : HRESULT 0x80041003,Microsoft.Management.Infrastructure.CimCmdlets.GetCimInstanceCommand
0x80041003 is WBEM_E_ACCESS_DENIED, the status the WMI namespace security
check returns; Get-CimInstance fails for any class and takes Get-NetTCPConnection,
Get-NetIPAddress, Get-NetAdapter, Get-Volume and Get-ComputerInfo with it.
This family's mechanism is documented by the harness itself, and that is a fact
the advisory states rather than conceals: the Windows ACL backend's own README
says "Authenticated Users is absent from both lists — the WMI namespace security
check fails (0x80041003), so CIM cmdlets and Get-ComputerInfo are unavailable
in every confined mode", the token builder's doc comment repeats it, and two arms
of that package's runner suite pin CIM: DENIED under both confining modes. The
plugin carries the family anyway, for the reason the ACL family carries a
documented prerequisite: the error does not name it, and the moment the
diagnosis is needed is the moment a CIM command has just failed for no visible
reason. So the advisory quotes upstream's sentence as the authority and says what
it adds on top, instead of presenting a documented boundary as a discovery.
What it adds is the half the documentation does not supply. First, a measured
native substitute for each command that stops working — [discussion #9272]'s
own measurements, taken in one session beside the refusals they replace:
| stops working | use instead |
|---|
Get-NetTCPConnection -State Listen | netstat -ano (parse the LISTENING rows) |
Get-CimInstance Win32_LogicalDisk (free space) | [System.IO.DriveInfo]::new('C').AvailableFreeSpace |
| memory | Get-Counter '\Memory\Available MBytes' |
Get-CimInstance Win32_OperatingSystem (OS version) | registry: HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion |
Get-Process / Get-Service | unaffected — neither goes through CIM, which is why they are listed here as the measured controls |
Two commands in that family have no measured substitute — Get-NetIPAddress
and Get-NetAdapter — and the advisory states that gap rather than filling it:
ipconfig /all is a plausible candidate that was never measured under a confining
mode, so it is named as a candidate and not handed over as verified.
And the half that matters most to an agent: two shapes that answer with a wrong
value and no error at all.
-ErrorAction SilentlyContinue (or $ErrorActionPreference = 'SilentlyContinue')
turns every refusal into an empty result:
(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue).Count → 0.
A caller reads that as "nothing is listening" when the query never ran.
Get-PSDrive -PSProvider FileSystem raises no error at all while reporting
Used/Free as 0 for every drive, because PowerShell 5.1 fills those columns
from a WMI query — while [System.IO.DriveInfo]::new('C').AvailableFreeSpace is
correct at the same moment. "0 bytes free" is a value an agent will act on.
Neither shape leaves anything for a classifier to key on, which is why the plugin
stays silent on them and delivers the rule inside the advisory attached to a
recognized refusal. That is stated rather than left implicit, so a later reader
does not mistake the silence for an oversight.
Why it is read from a successful result, like §3 and §4. A refused cmdlet is a
nonzero exit, and the shipped shell tools report those without marking the result
an error (packages/shell/tool-pwsh/src/render.ts: "Non-zero exits are reported,
not errored … only infrastructure failures (spawn errors, aborts) surface as
isError results"). So the code lives in the settled value's own stderr and
never in error.message, and a plugin reading only error results is structurally
blind to it. The read is the value's captured stderr field rather than assembled
text, so a command that merely prints such a record — a log being grepped, a
transcript being re-raised — is left alone by construction.
The discriminator is the code, not the sentence, because the sentence is
localized: the reporter's own install prints 拒绝访问 where an English one prints
"Access denied", while 0x80041003 is carried verbatim in the error record's
FullyQualifiedErrorId in every display language. The code alone is not enough
either — a quoted line must not make the plugin diagnose a working environment — so
the same stderr must also carry one of the product's own untranslated names
(HRESULT 0x80041003, WBEM_E_ACCESS_DENIED, CimException, CimCmdlets,
cimv2).
It names no repair, and says why. No icacls line and no elevation: this
refusal names no path and is not about a file, so no ACE closes it. Passing under
danger-full-access confirms the diagnosis and is not a fix — it removes the
sandbox, and with it the reason the session was confined. And the one narrow
allowance a reader naturally proposes — read access to the root/cimv2 namespace
— is a change to the restricted token's own lists rather than to anything on disk,
priced against a deliberate trade: the same absence of Authenticated Users from
those lists is what upstream lists as closing the C:\-root tree-creation escape.
Nobody has measured which of the token's checks refuses the namespace (the
second access pass, the mandatory label, or the namespace's own mask), so the
advisory declines to price a narrower fix and says so.
The family is gated on the platform and on the resolved mode, like §3 and §4: the
condition is a property of one backend's restricted token, so a session on another
host — or one whose mode does not confine — is not this story and is withheld
rather than guessed at, with the host log accounting for the silence.
[#9272] is a Discussion (the repository has issues disabled), and the reply
covering this family is posted there, together with the four neighbours that make
the boundary visible as one thing rather than four puzzles: [#1157] (no outbound
network under the same restricted token), [#4163] and [#997] (Schannel TLS failing
with SEC_E_NO_CREDENTIALS) and [#1847] (external processes dying with
0xC0000142).
7. An ACL refusal that withholds its own cause (acl-aggregate-failure, added in 0.19.0)
[discussion #9460] reports the one failure in this plugin whose line is the
finding, because the line is all there is:
sandbox-local windows-acl temp grant materialization failed and its cleanup also failed
That text is the message of an AggregateError, and Node builds such a
message from the argument the thrower passed — not from its members. The
producer (packages/sandbox/sandbox-local/src/index.ts) throws it when the
Windows ACL backend's own fail-closed cleanup failed together with the grant that
had just failed, so the members hold the inner failure and with it the Win32 code,
the API name and the directory being edited — and none of that is in the text.
The runtime hands the session error.message (errorMessage() in
packages/core/tools/src/index.ts), so the code and the directory are
unreachable from the session by construction, not by truncation.
Every other family here names a cause. This one cannot, and the advisory does not
invent one: it states what the line is, says what the line does say (that two
throws happened — the grant and its cleanup), and then hands over candidates in
the order a reader can separate them. Two sentences exist, one per half, and the
difference is real rather than cosmetic: the workspace half is the standing
root ACE, whose cleanup only frees the grant's SIDs (that ACE is the reuse cache
and is kept on purpose), while the temp half is the per-session private
capability, whose cleanup revokes the temp ACE and removes the private directory.
The candidate that leads needs no ACL at all. Every grant and every revocation
runs under one per-path lock, and the lock file is created before any security
descriptor is read or written:
<GetTempPathW()>\dsh-acl-locks\<first 16 hex of sha256(lowercased path)>.lock
A lock root that cannot be created or opened fails the grant and its cleanup
together, producing this exact sentence having attempted no ACL operation. It is
also the only candidate a directory listing can settle — and the listing needs no
privileges:
dir "<temp root>\dsh-acl-locks"
The lock file is opened with OPEN_ALWAYS and never deleted, so an absent or
empty directory after a failure is itself the finding. Behind it come the
documented prerequisite (missing WRITE_OWNER, and WRITE_DAC for a directory
the caller does not own — a right on the directory, not SeSecurityPrivilege
and not elevation) and the directory itself. The private dsh-XXXXXX directory is
created before the grant, so a failed call leaves it behind — which is the trace
to search for, and the reason a redirect that reached only one of the two temp
roots (os.tmpdir() chooses the private directory, GetTempPathW() chooses
dsh-acl-locks) can look like it did nothing.
It ships no repair command, for the reason the standing-grant section ships
none: the line names no directory, and a grant aimed at the wrong one is worse
than none. What it hands over is the listing that names the directory, and then
the backend's own diagnose-windows-sandbox-acl skill — with the version boundary
that the skill arrives with the 0.2.0 line, so a 0.1.7 install should not go
looking for it. It also names the two repairs [#9460] measured as not working,
each with its reason: takeown /A moves ownership to Administrators, whose
implicit rights are READ_CONTROL and WRITE_DAC and therefore not WRITE_OWNER;
icacls /reset /T /C restores an inherited Authenticated Users:(M) entry that
carries neither right.
Finally, it discards an inference the report itself made and labelled as one:
a visible probeTimeoutMs in the Config schema invites "the capability probe
timed out". This backend has no capability probe — its Windows chain is a single
rung selected without probing, and the grant path contains no probe at all — so no
timer can produce this sentence, and the advisory says so rather than leaving a
plausible wrong mechanism standing.
It needs no platform gate, for the reason the first family needs none: the
sentence names sandbox-local windows-acl, a producer only the Windows ACL backend
can be. It also needs no mode gate, because the grant is materialized only on the
session-scoped confining path — the sentence's presence already states the mode,
and asking the resolver for a second opinion could only add a way to withhold
advice the line already justifies.
[#9460] is a Discussion (the repository has issues disabled), and the reply
covering this family is posted there.
What it does with a recognized failure
-
One durable advisory per agent, per family. An agent that hits two
families is told about both, once each. The notice carries its own
producer-owned source.kind (sandbox-grant-advisor) — not the retired
plugin wrapper, which the current session format refuses — and a bounded
one-line summary for the transcript row. The host log gets one matching
warn line, so the fact survives outside the transcript too.
-
A disclosure when it withholds. The PTY and native-init advisories are
only sent when the
resolved mode actually confines, and the workspace-denial advisory only when
the workspace root is resolvable — the fact its containment claim is tested
against. If the mode is danger-full-access, or either fact cannot be
resolved at all (no sandboxPolicy service mounted, no agent session, a
resolver that throws, a policy without a root), the failure is left exactly as
it was and the host log says so once. Silence alone would make "the sandbox is
not the cause" and "this plugin could not tell" indistinguishable from the
outside. Withholding is never a guess: an unresolvable mode is not an
invitation to fall back to the deployment default.
-
An optional, bounded fail-fast half (enforceAfter, default 0 =
off) — ACL family only. It refuses a call before dispatch
(tools/pre-execute) only when both hold: the environment has failed
provisioning at least enforceAfter times, and this exact call (tool +
canonical arguments) is one this plugin watched fail. The budget is
maxDenials (default 2), after which the call proceeds again. The budget is
per episode of brokenness: a call that finally succeeds stops being a
denial target and re-arms it, so an environment that breaks twice can be
refused twice — while a session can always make progress by spending the
budget it has.
Why the blocking half covers one family only (it is ACL-only by
construction, in the parameter type): the ACL remedy is a
command
the user can run while the session continues, so refusing further identical
calls cannot make the session unfinishable — spending the budget always lets
the call through, and a repaired environment is discovered by exactly that.
The PTY remedy is a preset swap, which happens between sessions, and the
native-init remedy is a launch fix on the user's side — whose one in-session
part, rewriting an MSYS2 command, the model does by calling a different
command, which has a different call key and is therefore never the call being
refused. The workspace-internal denial is not covered either, and for a
stronger version of the same reason: its remedy is not a command at all — the
object was skipped when the grant was written and nothing revisited it — so
refusing calls could only pad a session that is already unable to do the thing
being refused.
Install
npm install @argszero/cordis-plugin-sandbox-grant-advisor
Mount it by adding the patch to your profile, or apply the shipped
cordis.patch.yml:
- insert:
- id: sandbox-grant-advisor
name: '@argszero/cordis-plugin-sandbox-grant-advisor'
Configuration
| option | default | meaning |
|---|
enforceAfter | 0 | Provisioning failures in one agent after which an identical, already-failing call is refused before dispatch. 0 disables the blocking half entirely. |
maxDenials | 2 | Denials one agent may spend per episode of brokenness. Bounded on purpose; re-armed when a watched call finally succeeds. |
include | [] | Tool-name wildcard patterns to watch; empty means every tool. |
exclude | [] | Tool-name wildcard patterns never watched. |
href | — | URL quoted in the advisory as the upstream thread, instead of the discussion numbers. |
- set:
- id: sandbox-grant-advisor
config:
enforceAfter: 3
include / exclude narrow every family: an untracked call is
transparent to the plugin entirely, so a watched-out shell call produces no PTY
advisory (and no withholding note either — the configuration said "not our
story", which is different from "we could not tell").
What it deliberately refuses to explain
Recognition is narrow, because a classifier that names the wrong cause is worse
than one that stays silent.
- Only the two
...NamedSecurityInfoW operations are classified.
SetEntriesInAclW merges access entries in process memory — there is no
object and no rights involved — so its failure is not an ACL-permission
problem, and neither are the LocalFree, LockFileEx,
SetConsoleCtrlHandler or SetEnvironmentVariableW failures thrown by the
same package.
- The PTY family is matched on a whole line, not a substring. The producer's
message is the sentence (
PTY shell exited during startup) with no detail
field at all, so any longer line that merely contains it is something
quoting it — a transcript, a log a failing command printed, a pasted issue
body — and the harness is not the producer.
- The sibling throw is not classified.
PTY shell did not reach readiness before startup timeout means the shell started and then did not reach a
prompt: a different cause space (a slow or blocked shell) with a different
remedy.
- The Win32 code is kept, not flattened.
ERROR_ACCESS_DENIED (5) is the
case the documented prerequisite explains; another code gets a different
paragraph that says so instead of borrowing the same sentence.
- A successful command whose output contains the line is not a failure.
The gate is the result's error state, not the presence of the text — reading a
log file that quotes the error must not trigger advice.
- Only
STATUS_DLL_INIT_FAILED is classified, and only from the canonical
value. 0xC0000409 is the Cygwin/MSYS2 runtime's deliberate fast-fail (a
different mechanism with a different story) and 0xC0000135 is a missing DLL
(a packaging problem, not a sandbox one); both are refused, as is exit 127,
which upstream's runner-failure rule already owns. And because the code is
compared as a number in ToolExecutionSuccess.value, a command printing
[exit code: -1073741502], or any value that is not the shipped shell
projection (kind: 'foreground'), is not this family — a line of text can
never be mistaken for a loader status.
- A denial is not this family unless the plugin can place it. A denial under
read-only, under danger-full-access, or on a host that is not Windows; a
value the executor marked as a runner failure (denied is not set when the
runner itself failed); a command naming a path outside the workspace; a command
naming an outside path as well as an inside one; and a command naming only
relative paths — all seven are refused, and the first three are the
outcomes rather than puzzles. The plugin reads the executors' own stamp rather
than a sentence, so a command whose output contains is not this
family either.
Honest boundaries
- The Windows path itself cannot be witnessed on macOS, where this plugin
was built. What the test suite proves is the decision layer — classification
of all seven families (the third from the producer's own canonical value,
built by the suite with the reported
-1073741502 and the report's stderr
line; the fourth from the executors' own stamp, built by the suite with the
mode, the denied flag and the refusal dialect the executor matches; the fifth
from the producer's own sentence with both operands, in both carrier spellings,
plus the pre-flight containment computed on real directories; the sixth from
the record #9272 pasted, in both display languages, with the silent shapes
asserted not to be recognized; the seventh from the producer's two
AggregateError sentences, thrown as real AggregateErrors whose members
carry a Win32 line naming a real directory, with the layer kept and the member
text asserted not to reach the advisory, and with the more specific ACL
family asserted to win when the message also carries a ...NamedSecurityInfoW failed line), the
once-per-agent-per-family rule, the sandbox-mode gate
and its fail-closed behaviour, the fail-fast budget and its self-feeding
guard, and the wiring to a real cordis Context and the real ToolRuntime —
driven by fixtures that throw the producers' exact error shapes
(Win32Error, packages/subprocess/win32-process/src/errors.ts; the
terminal throws, packages/terminal/terminal-bash/src/{index,session}.ts). It
does not prove that icacls ... :(OI)(CI)F fixes a given machine, nor that
a given Windows host reproduces the PTY startup failure; those are the user's
one-line experiment and the reporter's own control, and both advisories say
where they stop. The native-init family is the one that needs no Windows to be
faithful, because what it reads is a number inside a JSON value. The
workspace-denial family is exercised the same way and does need the
platform fact, which the suite supplies by stubbing process.platform for the
arms that need win32 and restoring it — the plugin reads the real fact rather
than a config knob, because a knob would be a backdoor into a shipped decision.
The temp-root family needs the same stub for its pre-flight half, and its
containment claim is exercised on real directories (a real ancestor of this
host's os.tmpdir()), so the realpathSync.native comparison the backend
performs is really performed here rather than simulated on strings.
The CIM/WMI family needs the stub too, and its fixtures are the record [#9272]
pasted verbatim — the Chinese 拒绝访问 an English install renders as "Access
denied" — so the reason recognition keys on the code rather than on the sentence
is exercised rather than asserted. Its two silent shapes are asserted to
be recognized: there is nothing for a classifier to key on, and that is the
disclosure rather than an omission.
What that proves is the decision layer against the producers' stamped values;
the ACE-inheritance path itself is still unwitnessed here, and the advisory
says as much by shipping no repair command.
Compatibility
Harness peers — all three carry the same range, quoted in full on purpose
(a partially quoted range admits fewer lines than the published one):
@deepseek-ai/dsh-llm@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0 — the only runtime
import: createUserMessage and boundContextSummary.
@deepseek-ai/dsh-agent@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0 and
@deepseek-ai/dsh-tools@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0 — imported for their types only (Agent,
ToolExecution, PostToolDecision, …). Nothing is loaded from them at
runtime, but the shipped lib/types/index.d.ts still names them, so a consumer
on a line outside this range fails to typecheck against this package's own
declarations; that is a compatibility claim, and it is declared as a peer for
that reason.
@deepseek-ai/cordis@^4.0.2.
@deepseek-ai/dsh-sandbox-policy is not a peer: the PTY family's mode
lookup is a guarded, structural one (ctx.get('sandboxPolicy')) precisely so a
composition that does not mount the service degrades to silence instead of
failing to load. See src/mode.ts.
Probed at the newest build of every line the range admits — 0.1.2-rc.1,
0.1.3-alpha.2, 0.1.5-rc.3, 0.1.6-alpha.2, 0.1.7-rc.2 (the newest build of
the line the later Windows reports ran on) — with npm run test:probe-lines,
which derives those builds from this range, installs each one from the registry
into a scratch tree and runs the suite against it. 0.1.7-rc.1, the build the
third report ran, is admitted by the same || segment and was probed while it was
the newest of that line.
The whole set is re-probed whenever this package's source changes rather than
carried over from an earlier version: the range is a claim about this build of
the plugin, so 0.7.1 re-ran all five lines above, 0.9.0 re-ran them again,
0.16.0 re-ran them a third time, 0.17.0 a fourth, 0.18.0 a fifth and
0.19.0 a sixth — all
five PASS each time. A
line whose probe fails is removed from the range rather than left claimed. The
scratch tree's resolved versions are the ones to read back when a probe is quoted
as evidence — the probe script pins them by exact version, and --keep leaves the
tree in place to check.
The next line's pre-releases are outside that range on purpose, and the guard
in test/packaging.spec.mjs enforces it (a range that admits 0.2.0 fails the
suite). 0.2.0-rc.2 — the build the newest report in this family runs, including
its Desktop variant — was nevertheless probed by hand (npm run test:probe-lines -- 0.2.0-rc.2) and the suite goes green there, which is the honest reason to expect
the plugin to work on it. It is a measurement, not a claim: the range widens when
the 0.2.0 line is the released one rather than its pre-release.
The mode lookup stays guarded through this version too: the native-init family
needs the same resolved mode as the PTY family, and it takes it from the same
ctx.get('sandboxPolicy') capability guard, so a composition without the service
degrades to a disclosed silence rather than failing to load.
Development
npm install
npm test # tsc, then the suite (real cordis + real ToolRuntime)
npm run test:inject # defect injection: mutate the source, rebuild, require the suite to go red
npm run test:probe-lines # install the newest build of each admitted line and run the suite against it
npm run test:probe-lines -- 0.1.7-rc.1 # one line only
The suite is mostly control arms: a guard that explains the wrong failure, or
refuses