跳到主要内容

cordis-plugin-sandbox-grant-advisor

已验证

@argszero/cordis-plugin-sandbox-grant-advisor · v0.18.0 · MIT

Turns five sandbox environment failures that name neither their cause nor a remedy into a diagnosis with a path forward. Family 1, the Windows workspace ACL: twelve reports (#7538, #7622, #7646, #7720, #7750, #7735, #7771, #7804, #7816, #8232, #8272, #827

安装

dsh plugin add @argszero/cordis-plugin-sandbox-grant-advisor

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

标签

说明文档

@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. Six 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

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 six 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 is read from a thrown error's own message like the second, and 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:

  1. 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").

  2. 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.

  3. 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.

  4. 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.

    There is a second boundary on that same line, and #8272 is what made it worth stating: the backend's own repair, diagnose-windows-sandbox-acl, is not part of 0.1.7-* at all — it arrives with the 0.2.0 line, where the package starts shipping it under assets/. A reader who found the skill named in a README while running 0.1.7-rc.2 was reading a 0.2.0-era document, not a package that dropped something: that release's own README names it zero times and no file in its tree carries the registration symbol. So the useful reach is the upgrade, and the unhelpful one — repairing a files glob in a release that has no such directory — is not offered.

  5. The repair is per-directory, and the report is what established that. #8232 applied the (WO) line, watched the workspace start working, and then hit the same error on a second workspace root on the same volume. (OI)(CI) carries the ACE into children of the directory that received it and nowhere else, so a sibling root is untouched by it. One line per workspace root is therefore the correct shape of the remedy, not one line per machine — and the advisory says so, because the natural reading of "it worked" is "it is fixed".

  6. A "weaker grant" is a mechanism this backend cannot express, not a policy it declines (added in 0.9.0, from #8275). The label is what makes the apply a SACL write, so declining it looks like dropping the expensive half — but there is no DACL-only path to fall back to: the label rides the same SetNamedSecurityInfoW as the grant (acl.ts: the flags are DACL_SECURITY_INFORMATION alone only when the label edit is keep, and grantWrite's apply branch always passes apply), and the idempotence fast path requires the exact label among its three conditions. And dropping the label alone would not leave a working workspace: the confined token is itself lowered to Low before any child starts (token.ts's restrictTokenIntegrity, whose own comment calls Low "the level the mandatory labels grantWrite applies are matched against"), so the directory's Low label is what lets that Low child write there at all under no-write-up. A DACL-only mode that keeps the token lowering yields a workspace the sandbox can start a command in and the command then cannot write to; one that drops the token lowering too — which is what #8275's own appendix records a community patch having to do — gives up half the confinement rather than one of two independent layers. The advisory says this instead of a bare "no", because a reader told only "no" reaches for the workaround without knowing what else it has to change.

  7. The missing right was confirmed from outside, unelevated (added in 0.14.0, from #8426). Everything above is argued from the backend's source; #8426 measured it with none of that. On a directory whose ACL named the account only through the inherited Authenticated Users:(M) entry, asking that directory for a Low mandatory-integrity label — the label half alone, no DACL write, so it isolates the right the merged call wants — was refused, and the identical operation succeeded the moment the same account granted itself (OI)(CI)F, whose mask carries WRITE_OWNER, and was reversible from there (back to Medium works just as well). No elevation anywhere in it. That makes the claim in item 2 a measurement rather than a reading: the missing right is an object right on the directory, not SeRelabelPrivilege and not a token privilege. The same report then repaired two real trees the same way — object right first, integrity label second, 9521 and 5592 objects, no failures — an order that cannot be swapped, since the second step is the one that needs what the first supplies, and it is the order the advisory's own remedy already follows. The probe itself is quoted here and not in the advisory, for the reason item 6 of the #8272 paragraph above gives: a label write is not a check, because where the caller already holds Full control it succeeds and writes the label. The advisory hands over only the two read-only checks.

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 simply removable here: ... This advisory hands over no removal command: the
  maintainers' own skill does not, and an unverified one would be the defect this plugin exists to answer.
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)

[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.
  • No removal command is shipped. 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. This project has no Windows host to verify a line on, so shipping one would be exactly the defect the rest of this module exists to answer; the section says where it stops instead.

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)

Twelve 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, and [#9336], which filed the combination a bare mode switch mis-attributes and the two costs that go with it. 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.

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:

  1. 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.

  2. 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.

    Since 0.18.0 this axis has its own measurement, and it is the reason the third producer's check is two steps rather than one. [#9238] built a ~90-line Win32 launcher that varies only the runner's console topology, held everything else — machine, workspace, temp directory, mode, argv, restricted token — constant, and ran six arms on one machine: DETACHED_PROCESS (no console at all) died with 0xC0000142 and zero output, while CREATE_NO_WINDOW (Windows allocates the runner an invisible console), CREATE_NEW_CONSOLE, inheriting an existing console, and a hidden-console wscript → cmd carrier all reached the program with its stdout intact, and the same DETACHED_PROCESS launch without the restricted token was fine. So the console is not an inference from two host binaries any more: it is a variable one launcher can turn on and off, which is what makes "does the runner own a console" a check rather than a suspicion about a build. The same report states this family's CI blind spot in one sentence and it is worth quoting in the plugin's own words: the repository's Windows checks run in a console-bearing chain — the one configuration the matrix shows working — so a green check there cannot falsify this producer. The clarification [#9238] adds to the backend's recording is the distinction the flag vocabulary below turns on: CREATE_NO_WINDOW on the restricted child is fatal, while CREATE_NO_WINDOW on the runner is the fix, because Windows then gives the runner an invisible console for the child to inherit.

    0.7.x named two shapes and explained them with the wrong mechanism, and 0.8.0 withdraws one of the shapes outright. The withdrawn shape is "the runner does not start at all, because ELECTRON_RUN_AS_NODE=1 is missing": the desktop sets that variable on its own host child (apps/desktop/src/host-process.ts → desktopNodeEnvironment(), apps/desktop/src/node-environment.ts:15), so nothing on the runner path can fail to start for want of it — and [#8208] measured the variable present in both hosts, including the one that works. The token story goes with it, for the same measurement. A cause the advisory shipped twice earns its sentence when it is retracted, and test arms keep both retractions in place.

    The remedy is a real node host — with one condition riding on it. [#8193] measured the way out: hosted on the desktop's own bundled standalone node (resources/runtime/primary-runtime/dependencies/node/bin/node.exe, v24.21.0) the same confined pwsh.exe / cmd.exe spawns succeed (exit 81 / 82), with workspace, temp directory, mode, SIDs, target and runner sha256 all held constant. [#8208] reproduced it (exit 0, the command's own stdout intact) on the same runtime installed for workspace dependencies (%USERPROFILE%\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe, present once load_workspace_dependencies has run) and with windowsHide both set and unset. The condition is that the runner must be spawned with a console — not with DETACHED_PROCESS — because a real host alone is not enough when that flag is set ([#8208]): the host binary supplies the console and the spawn flag is what can take it away again. Where no real host can be put in front of the runner, the same effect fits inside it: AllocConsole before the restricted spawn, with the three standard handles restored afterwards, in dsh-win32-process's createRestrictedProcess — the funnel every restricted child goes through. [#8208] measured cmd /c exit and pwsh -c "Write-Output …" both reaching 0 with stdout intact under that change, and as a no-op on a runner that already owns a console; it costs a user32 binding and one console host per runner process. danger-full-access is demoted to what it actually is — a way to confirm the diagnosis, not a fix, and on this platform one that silently removes the sandbox from every shell call. The reporter's own reason is the one the plugin repeats: "the practical effect is that Windows Desktop users must escalate to full access for all shell work, which silently removes the sandbox on that platform", and they asked explicitly that this not be "fixed" with --disable-sandbox / --disable-gpu-sandbox — those disable Chromium's renderer sandbox, a different layer from the DSH file policy. The advisory also warns off the opposite-looking move: unsetting ELECTRON_RUN_AS_NODE does not help, because the desktop's runner is that Electron binary and dropping the variable would take runner.js down with it — the interaction [#8193] records with [#8174], where a fix that tombstones the variable in the shared child environment would take the ACL runner down with it, so the two changes have to land together.

    What the flag vocabulary actually is. The backend's own source records one inherent boundary: "console isolation is unavailable — children share the host console (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with STATUS_DLL_INIT_FAILED under the restriction)" (packages/sandbox/sandbox-windows-acl/README.md:119), echoed at packages/subprocess/win32-process/src/process.ts:454. [#8208] explains that recording instead of repeating it, and the explanation is the invariant the two shapes share: in a restricted token a console can be inherited but not created. The creation flags actually passed are three sets and none of them is CREATE_NO_WINDOW — 0 on the piped path (process.ts:243, the path a shell call takes), CREATE_SUSPENDED on the inherited-job path (process.ts:542) and CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT on the ordinary path (process.ts:567, i.e. 0x404); CREATE_NO_WINDOW (0x08000000) is not a constant anywhere in that source. Those flags are fatal under a console-less runner and harmless under one that owns a console — so there the console decides. 0.7.x wrote the two-set version of that list and called the flags "a necessary ingredient ... the host process image is what turns it fatal"; both halves were wrong, and 0.8.0 replaces them.

    0.11.0 and earlier concluded too much from those three sets — "it is the console and not the flag list that decides" — and [#8336] measured the case that falsifies it: with a console-owning host, a restricted token and the Low integrity level, varying only the creation flags, CREATE_NO_WINDOW and CREATE_NEW_CONSOLE both died with 0xC0000142, while 0, DETACHED_PROCESS and CREATE_NEW_PROCESS_GROUP reached the program. So the console decides whether a flag that merely shares the inherited console is fatal, and a flag that forces the child to create one of its own is fatal regardless. The pair inside that matrix is what makes it a rule rather than a list of forbidden flags: the same CREATE_NO_WINDOW on an unrestricted token reached the program, and STARTF_USESHOWWINDOW with SW_HIDE — how the harness hides a window without isolating a console — was harmless on the restricted one. 0.12.0 replaces the withdrawn sentence with the rule and that matrix.

    The windowsHide suspicion, answered. It is the first thing a search turns up for this failure, and it points at the wrong component: the flag appears once on the ordinary subprocess path (packages/subprocess/subprocess-local/src/spawn.ts:472, windowsHide: platform === 'win32'), which starts the runner rather than the confined child, and the restricted spawn defines no CREATE_NO_WINDOW constant at all. It is not set in dsh-jobs — a search there finds nothing, and every windowsHide in the tree sits on a host-side or ordinary spawn. And where it is set, [#8208] measured it both ways on a host that works: with and without windowsHide, the confined pwsh reached exit 0 with its own stdout intact. The reason is the rule again — a windowless console is still a console, and that is what the child inherits; what decides is whether the host owns a console object, not whether it owns a window.

    One arm was applied, measured, and rejected. Putting DETACHED_PROCESS on the restricted child removes its console request and does stop the crash — but pwsh then exits 0 with zero bytes on stdout and stderr, while cmd.exe keeps its output ([#8208]). That trades a loud failure for a silent one on the interpreter most likely to be used, so the advisory names it as a non-remedy instead of offering it; the runner-side remedies keep the output.

  3. A capability SID inside the restricted token's own restricting list ([#9186], added in 0.16.0). The two producers above are properties of the program and of the host; this one is a property of the token, and that is why it is the hardest to see: the reporter drove the sandbox API directly, with the runner hosted by a real node binary and the DACLs left untouched, and held everything fixed but one input — the restricting list the restricted token is built from. With the workspace-write list — [logon SID, EVERYONE] plus one capability SID — every program died this way, whoami.exe and cmd.exe as well as pwsh; with the read-only list, which carries no capability SID, the same program started normally in both stdio shapes (piped and inherited). The capability SIDs are derived per workspace and per private temp directory and join the list only under workspace-write (sandbox-windows-acl/src/token.ts: createRestrictedToken() is verbatim read-only ? [logonSid, world] : [logonSid, world, ...writeSids]), so read-only carries none by design.

    The check is a mode switch, and since 0.18.0 it is the FIRST of two steps rather than the whole answer. Hold the command and the tool fixed and change only the mode: if read-only starts the command that workspace-write kills, the sandbox is the difference. That is one setting rather than a debugging session — and it is the answer to the one thing 0xC0000142 cannot say, because the same code runs in both directions: the restricted-token layer's own docstring records that a token built without the logon-SID + EVERYONE keep-alive pair also dies in early DLL init with exactly this status (and pwsh earlier still, in its CNG path, as 0xE0434352). Too few entries in the restricting list and too many land on the same 0xC0000142, so the code cannot name its own direction; only a comparison can.

    What the switch cannot do is name the mechanism inside the sandbox, and [#9336] is the report that proves it. That reporter ran the packaged desktop's chain and the ordinary explorer → cmd → pwsh chain against the same runner with the same argv: from the desktop chain cmd, PowerShell 7 and PowerShell 5.1 all died under workspace-write and all three reached exit 0 under read-only, while the console-bearing chain returned 0 in both modes (and still enforced the sandbox — an out-of-workspace write exited 1 with no file created). That is exactly what producer 3 looks like from the outside, and it is producer 2: the failing combination is a console-less host AND a confining token, and a mode switch made inside a console-less host separates for both reasons at once. So the second step is "does the runner own a console", it is answered by producer 2's own arm (start the runner from a DETACHED_PROCESS chain — no desktop, no particular machine), and this producer is the answer only when the runner did own one — the shape [#9186] measured, with a real node host and the DACLs untouched.

    A second candidate rides the same token, and it is carried as a candidate. [#9336] reasoned that adding SIDs to a restricting list only widens write access, so the more suspicious input is the ACE the backend merges into the token's default DACL. That reading is right about the asymmetry and unmeasured about the effect: setTokenDefaultDaclGrant (sandbox-windows-acl/src/token.ts) merges one full-access restricting-SID ACE into the existing default DACL — SetEntriesInAclW, GRANT_ACCESS, FILE_ALL_ACCESS, extended rather than replaced — and the SID it names is tempWriteSid ?? writeSid ?? Everyone (src/index.ts:304): the private temp SID under workspace-write when a temp directory exists, otherwise the workspace SID (a sha256 of the canonical workspace path, S-1-4-x-y), and Everybody under read-only. So workspace-write is the only mode whose default DACL names a synthetic identity at all — a real second difference between the modes — and nobody has measured that ACE. The merge is also load-bearing in a way that forbids the obvious experiment: every new object the confined process creates takes its DACL from there, so an ACE removed to test the theory takes every piped grandchild spawn down with it (spawn EPERM). The advisory names it, says it is unmeasured, and says not to delete it.

    And the switch costs something, which the backend's README states. Under read-only PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage, where Add-Type, non-core .NET static calls, COM and reflection fail — [#9336] adds [System.IO.File]::* and Get-CimInstance to that list — while the shipped workspace-write path lets the probe complete and keeps FullLanguage unless the machine carries a host-wide WDAC/AppLocker policy (packages/sandbox/sandbox-windows-acl/README.md:192). A command that "works" after the switch may therefore be a command that no longer runs at all: the switch is a diagnostic, not a repair. One thing this producer is not: .NET. Pure-native programs die here identically, which retires the "self-contained .NET runtime" cause the earlier reports converged on — that is the report's own control arm, not an assertion of ours.

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 ...

     A second candidate sits on the SAME token and is NOT the restricting list: the token's DEFAULT DACL ...
     it is a CANDIDATE and not an answer — nobody has measured it ...

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)

[#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 breadth, the one fact the reader already owns: a handful of stubborn objects while the rest of the tree writes normally is the DACL branch, nothing below the root writable at all with the root itself the only writable place is the label branch, and the root itself refused — nothing writable, not even the root — is a third branch with a different cause and a different remedy (below). 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 in addition to the access check — so a DACL that is perfect everywhere changes nothing. [#8383] measured it on 0.2.0-rc.2 with the decisive control of a directory created after the grant: it inherits the capability ACE marked (I) and still gets no label. (The report's own care is worth keeping: a grandchild directory having no label proves nothing, because inheritance is per-parent and the intermediate directory carries none — only a direct 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 (diagnose-windows-sandbox-acl, 0.2.0 and later, prints the owner, the caller's rights and WRITE_DAC/WRITE_OWNER for the one and LOW_LABEL — S-1-16-4096 — 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 FullControl entry for the user on a subdirectory still left the write denied.
  • The third branch, and the one recovery this family has (added in 0.14.0, from [#8409]). The root's own standing grant can be gone: the workspace root's security descriptor rewritten from outside the harness (an ACL reset, a restored descriptor, a tool that re-applies one) leaves nothing writable, the root included. It is not a descendant missing an inherited ACE — it is the ACE the harness itself wrote that is no longer there — and the reason no retry reaches it is one layer above the backend's own check: the session-scoped provider materializes the root ACE once per provider lifetime and keeps the root in its own in-memory map, consulting the map instead of the DACL thereafter (sandbox-local/src/index.ts: the guard at :403-421, "materializes once per workspace per server lifetime"), so the hasExactGrant check the other two branches turn on is not even reached again. The discriminator is measured on both paths and is a fact about where the cache lives, not about one path being more careful: the agentless/runner invocation passes no session id (windowsAclRunnerArgv()), so the runner owns the DACLs and re-applies the grant on every spawn — which really does read the DACL — and heals by itself, while the desktop session does not. The recovery is the only one this family has and it is the user's, not a command: restart the provider (quit and reopen the desktop app, or open the workspace in a fresh one), which empties the map and lets the next provision read the DACL, find the ACE absent and write it — the reporter measured exactly that ("restarting the desktop brings it back").
  • No repair command. The obvious one, a recursive icacls /grant for the capability SID, is refused by Windows itself with ERROR_NONE_MAPPED (1332) — the tool cannot map that SID to a name, so a grant that must name it never reaches the child. The line that would work needs WRITE_DAC on the object, which is the right in question. The advisory states the mechanism, names the ceiling, and says out loud that it prints no command because this project has no Windows host to verify one on — the same standard §1's standing-grant section is held to, and for the same reason.

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 under that root. 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 platform gate is real here and cannot be dropped: the mechanism is ACE inheritance, which no other backend has.

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 inclu