@argszero/cordis-plugin-sandbox-grant-advisor 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,27 +1,32 @@
1
1
  # @argszero/cordis-plugin-sandbox-grant-advisor
2
2
 
3
3
  Turns a sandbox environment failure that has **no path forward** into a
4
- diagnosis the model — and the user reading the transcript — can act on. Two
4
+ diagnosis the model — and the user reading the transcript — can act on. Three
5
5
  signatures, one mechanism:
6
6
 
7
7
  ```
8
8
  SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws) # Windows workspace ACL
9
9
  PTY shell exited during startup # persistent shell × confining mode
10
+ [exit code: -1073741502] (0xC0000142) # a confined child that never started
10
11
  ```
11
12
 
12
13
  **This plugin is the stopgap for "the error does not name the outstanding
13
14
  condition".** It repairs nothing: no ACL is written, no privilege is requested,
14
- nothing is elevated, no preset is installed and no mode is changed.
15
+ nothing is elevated, no environment variable is set for another process, no
16
+ preset is installed and no mode is changed.
15
17
 
16
- ## The two failures it recognizes
18
+ ## The three failures it recognizes
17
19
 
18
- Both are recognized on the public **`tools/post-execute`** waterfall
19
- (`@deepseek-ai/dsh-tools`) — the one seam that has all three of: the failure text
20
+ The first two are recognized on the public **`tools/post-execute`** waterfall
21
+ (`@deepseek-ai/dsh-tools`) from the failure text. That seam is the one that has
22
+ all three of what a diagnosis needs: the failure
20
23
  (providers propagate their error unchanged and the tool pipeline settles it as an
21
24
  `isError` result), an agent identity to attribute it to (`exec.agent`), and a
22
25
  channel that speaks to the model in the same step (`PostToolDecision`'s
23
26
  `additionalContexts`, which the agent loop turns into a durable user-role message
24
- — `packages/core/agent-loop/src/tool-calls.ts`).
27
+ — `packages/core/agent-loop/src/tool-calls.ts`). The third is recognized at the
28
+ **same seam** from the canonical value of a result the pipeline calls a
29
+ *success*, for a reason §3 gives in full.
25
30
 
26
31
  That seam, not `ctx.sandbox.confine`: `confine(argv, policy, signal)` sees the
27
32
  confinement failure too, but its signature carries no agent, so a wrapper could
@@ -229,15 +234,126 @@ reads it any more (`@deepseek-ai/dsh-agent-preset-registry`: the registry
229
234
  by row id (`preset-minimal`) for a change to a shipped one. A test arm asserts
230
235
  the advisory never names the dead directory.
231
236
 
237
+ ### 3. A confined child that never started (`native-init`)
238
+
239
+ Three reports of one exit code: [`#7876`] and [`#8193`] (the packaged desktop
240
+ app, measured twice) and [`#7877`] (MSYS2 / Git Bash). All are `0xC0000142`
241
+ `STATUS_DLL_INIT_FAILED` — the Windows
242
+ loader terminated the process while it was initializing its native images, i.e.
243
+ **before the program's entry point**. A command that ran and then failed exits
244
+ with its own status and prints its own output; this one produced neither.
245
+
246
+ | what was run | what came out | exit code |
247
+ | --- | --- | --- |
248
+ | `cmd.exe /c "echo cmd-ok"` | `cmd-ok` | 0 |
249
+ | `pwsh -NoLogo -NoProfile -Command "Write-Output pwsh-ok"` | `pwsh-ok` | 0 |
250
+ | `D:\Git\bin\bash.exe -c "echo bash-ok"` | `couldn't create signal pipe, Win32 error 5` | `-1073741502` |
251
+
252
+ Two producers have been measured under a confining mode:
253
+
254
+ 1. **An MSYS2 / Git-Bash program** ([`#7877`]). The restricted token's runtime
255
+ cannot create the pipe it uses for signals, so bash aborts in the loader
256
+ phase, while `cmd.exe` and `pwsh` run fine in the same workspace under the
257
+ same mode. The plugin's own composition has no way around this: `tool-bash`
258
+ and `bash-sandbox` are `disabled` on win32
259
+ (`@deepseek-ai/dsh-base/cordis.patch.yml`), so the combination is likely
260
+ never covered upstream. The **one conversion a model can make itself** is to
261
+ write the same work as a PowerShell or `cmd` command.
262
+ 2. **The packaged desktop app's sandbox runner** ([`#7876`], [`#8193`]).
263
+ `dsh-sandbox-local` launches the runner as `[process.execPath, entry]`, and
264
+ in the packaged build `process.execPath` is the Electron executable. Two
265
+ measurements of that host exist and — from inside a session — they are
266
+ indistinguishable, so the advisory names **both** instead of asserting one:
267
+ **(a)** the runner does not start at all — the Electron binary begins as an
268
+ *application* unless the child's environment carries `ELECTRON_RUN_AS_NODE=1`,
269
+ so nothing on the runner path ran ([`#7876`]); **(b)** the runner *does*
270
+ start — the desktop launcher sets exactly that variable — and the command
271
+ still dies, because the restricted token is derived from the Electron process
272
+ image and the child does not survive being started under it ([`#8193`]). A
273
+ reader outside the session separates them (is `ELECTRON_RUN_AS_NODE` set for
274
+ that host, and does the ACL runner appear among its processes while the
275
+ command runs?); from inside the session they are one finding with one remedy.
276
+ The unpacked node host (`node apps/cli/lib/bin.js web`) is unaffected. The
277
+ plugin reports whether *this* process is an Electron binary
278
+ (`process.versions.electron`) as a measured fact rather than assuming it,
279
+ because that is the check the discriminator turns on. **0.6.0 asserted shape
280
+ (a) alone** — "so the runner never runs" — and handed the Electron reader a
281
+ cause that [`#8193`] measures to be false; the single-cause sentence is gone,
282
+ and a test arm keeps it gone.
283
+
284
+ **Why this family is read from a successful result.** The producer never marks
285
+ it an error, and that is a fact about upstream rather than a choice here:
286
+ `RUNNER_FAILURE_RULES['windows-acl']` admits exactly one code —
287
+ `[{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]`
288
+ (`packages/sandbox/sandbox-local/src/index.ts`) — and `classifyRunnerFailure`
289
+ skips any other code before it looks at stderr
290
+ (`packages/sandbox/sandbox/src/diagnostics.ts`), so `0xC0000142` is never a
291
+ runner failure and `SandboxUnavailableError` is never thrown. The renderer then
292
+ reports it the way it reports any finished command — *"Non-zero exits are
293
+ reported, not errored … only infrastructure failures (spawn errors, aborts)
294
+ surface as isError results"* (`packages/shell/tool-pwsh/src/render.ts`) — as
295
+ `[exit code: …]`. **Every version of this plugin before 0.6.0 read error results
296
+ only and was structurally blind to it**, which is exactly why the model retries a
297
+ command that can never start.
298
+
299
+ The read is `ToolExecutionSuccess.value` — the tool's own canonical output,
300
+ documented as *"Execution-local canonical value; deliberately omitted from
301
+ durable events"* **and not carried on failure results at all** — so the code
302
+ arrives structurally rather than as a line of text. A command that prints
303
+ `[exit code: -1073741502]` is not this failure, and neither is a value some other
304
+ tool happens to build with an `exitCode` field: the classifier requires the
305
+ shipped shell projection (`kind: 'foreground'` plus an integer `exitCode`).
306
+
307
+ The advisory that follows:
308
+
309
+ ```
310
+ Sandboxed command never started — the process died while its native libraries were loading.
311
+
312
+ What was reported:
313
+ [exit code: -1073741502] (0xC0000142 STATUS_DLL_INIT_FAILED)
314
+ The call ran under sandbox mode `workspace-write`, where the harness starts every command through its
315
+ restricted-token runner.
316
+
317
+ 0xC0000142 is STATUS_DLL_INIT_FAILED: ... this is BEFORE the program's entry point. ...
318
+ Nothing in the code says "sandbox" by itself; what makes the sandbox a candidate is the mode above ...
319
+
320
+ Two producers have been measured under a confining Windows mode. Check which one this is:
321
+ 1. An MSYS2 / Git-Bash program ... (#7877)
322
+ If that is what could not start: write the same work as a PowerShell or `cmd` command instead
323
+ 2. The packaged desktop application's sandbox runner ... (#7876)
324
+ This process is NOT an Electron binary (`process.versions.electron` is unset), so that producer does not apply here.
325
+
326
+ Do not retry this call: the environment has not changed, and the identical call produces the identical code.
327
+
328
+ Honest boundary — 0xC0000142 has producers this list does not have: a program that cannot load one of
329
+ its own DLLs dies this way too, and the sandbox backend's own source records that a child started with
330
+ a hidden console window does as well ... This is not a claim that the sandbox caused the failure.
331
+ ```
332
+
333
+ **What it does not claim.** The code is a loader status, and the loader reports
334
+ the same status for causes that have nothing to do with the sandbox (a missing
335
+ DLL, a program's own initialization failure, the hidden-console child the
336
+ backend's own source avoids `CREATE_NO_WINDOW` for). So the advisory diagnoses
337
+ the **class** ("the process never reached its entry point") and enumerates the
338
+ producers measured under a confining mode, each with the check that separates
339
+ them — one of which the reader answers (what program failed to start) and one of
340
+ which the plugin answers (is this host the packaged desktop binary). Where a
341
+ producer has more than one measured shape, the advisory names all of them and
342
+ records which check separates them outside the session, rather than asserting
343
+ the single shape that happened to be measured first. It never
344
+ offers `danger-full-access` as a fix and never suggests a sandbox setting be
345
+ relaxed.
346
+
232
347
  ## What it does with a recognized failure
233
348
 
234
- 1. **One durable advisory per agent, per family.** An agent that hits both
349
+ 1. **One durable advisory per agent, per family.** An agent that hits two
235
350
  families is told about **both**, once each. The notice carries its own
236
351
  producer-owned `source.kind` (`sandbox-grant-advisor`) — not the retired
237
352
  `plugin` wrapper, which the current session format refuses — and a bounded
238
353
  one-line `summary` for the transcript row. The host log gets one matching
239
354
  `warn` line, so the fact survives outside the transcript too.
240
- 2. **A disclosure when it withholds.** The PTY advisory is only sent when the
355
+ 2. **A disclosure when it withholds.** The PTY and native-init advisories are
356
+ only sent when the
241
357
  resolved mode actually confines. If the mode is `danger-full-access`, or
242
358
  cannot be resolved at all (no `sandboxPolicy` service mounted, no agent
243
359
  session, a resolver that throws), the failure is left exactly as it was
@@ -256,14 +372,18 @@ the advisory never names the dead directory.
256
372
  refused twice — while a session can always make progress by spending the
257
373
  budget it has.
258
374
 
259
- **Why the blocking half does not extend to the PTY family** (it is
260
- ACL-only by construction, in the parameter type): the ACL remedy is a command
375
+ **Why the blocking half does not extend to the two mode-gated families** (it
376
+ is ACL-only by construction, in the parameter type): the ACL remedy is a
377
+ command
261
378
  the user can run *while the session continues*, so refusing further identical
262
379
  calls cannot make the session unfinishable — spending the budget always lets
263
380
  the call through, and a repaired environment is discovered by exactly that.
264
- The PTY remedy is a preset swap, which happens **between** sessions;
265
- refusing calls there could only pad a session that is already unable to do
266
- the thing being refused.
381
+ The PTY remedy is a preset swap, which happens **between** sessions, and the
382
+ native-init remedy is a launch fix on the user's side — whose one in-session
383
+ part, rewriting an MSYS2 command, the model does by calling a *different*
384
+ command, which has a different call key and is therefore never the call being
385
+ refused. Refusing calls in those families could only pad a session that is
386
+ already unable to do the thing being refused.
267
387
 
268
388
  ## Install
269
389
 
@@ -328,6 +448,15 @@ than one that stays silent.
328
448
  - **A successful command whose *output* contains the line is not a failure.**
329
449
  The gate is the result's error state, not the presence of the text — reading a
330
450
  log file that quotes the error must not trigger advice.
451
+ - **Only `STATUS_DLL_INIT_FAILED` is classified, and only from the canonical
452
+ value.** `0xC0000409` is the Cygwin/MSYS2 runtime's deliberate fast-fail (a
453
+ different mechanism with a different story) and `0xC0000135` is a missing DLL
454
+ (a packaging problem, not a sandbox one); both are refused, as is exit `127`,
455
+ which upstream's runner-failure rule already owns. And because the code is
456
+ compared as a number in `ToolExecutionSuccess.value`, a command printing
457
+ `[exit code: -1073741502]`, or any value that is not the shipped shell
458
+ projection (`kind: 'foreground'`), is not this family — a line of text can
459
+ never be mistaken for a loader status.
331
460
  - **Only one advisory per agent, per family.** The environment is explained
332
461
  once; repeating it per failed command would be noise competing with the
333
462
  failure itself.
@@ -336,7 +465,9 @@ than one that stays silent.
336
465
 
337
466
  - **The Windows path itself cannot be witnessed on macOS**, where this plugin
338
467
  was built. What the test suite proves is the decision layer — classification
339
- of both families, the once-per-agent-per-family rule, the sandbox-mode gate
468
+ of all three families (the third from the producer's own canonical value,
469
+ built by the suite with the reported `-1073741502` and the report's stderr
470
+ line), the once-per-agent-per-family rule, the sandbox-mode gate
340
471
  and its fail-closed behaviour, the fail-fast budget and its self-feeding
341
472
  guard, and the wiring to a real cordis `Context` and the real `ToolRuntime` —
342
473
  driven by fixtures that throw the producers' exact error shapes
@@ -345,7 +476,8 @@ than one that stays silent.
345
476
  does **not** prove that `icacls ... :(OI)(CI)F` fixes a given machine, nor that
346
477
  a given Windows host reproduces the PTY startup failure; those are the user's
347
478
  one-line experiment and the reporter's own control, and both advisories say
348
- where they stop.
479
+ where they stop. The native-init family is the one that needs no Windows to be
480
+ faithful, because what it reads is a number inside a JSON value.
349
481
  - **It repairs nothing and elevates nothing.** If the directory really is
350
482
  Full-control for the caller, the remaining ACL hypothesis is
351
483
  `SeSecurityPrivilege` — i.e. the backend's documented prerequisite would be
@@ -365,13 +497,16 @@ than one that stays silent.
365
497
  outside the seam this plugin subscribes to. The report and its proposed fix
366
498
  stay with the maintainers; all this plugin can do is explain the provisioning
367
499
  failure that shares its root.
368
- - **The real fix is upstream, in both families.** For the ACL failure,
500
+ - **The real fix is upstream, in all three families.** For the ACL failure,
369
501
  `grantWrite` already computes `hasExactGrant` / `hasExactDeny` /
370
502
  `hasExactLabel` and discards which one was false, so the diagnostic that turns
371
503
  a 52-minute detour into one line belongs at that site. For the PTY failure,
372
504
  the startup path should either report "this sandbox mode is incompatible with
373
- the PTY backend" or fall back to a one-shot shell. This plugin is the stopgap
374
- for both.
505
+ the PTY backend" or fall back to a one-shot shell. For the native-init death,
506
+ the runner should be launched with the environment its own execution needs
507
+ (`ELECTRON_RUN_AS_NODE=1` when `argv[0]` is an Electron binary — [`#7876`]'s
508
+ three candidate fixes) or refuse, in a checkable way, an MSYS2 program under a
509
+ restricted token. This plugin is the stopgap for all three.
375
510
 
376
511
  ## Compatibility
377
512
 
@@ -404,11 +539,16 @@ the newest of that line.
404
539
 
405
540
  The whole set is re-probed whenever this package's source changes rather than
406
541
  carried over from an earlier version: the range is a claim about *this* build of
407
- the plugin, so `0.4.0` re-ran all five lines above. A line whose probe fails is
542
+ the plugin, so `0.7.0` re-ran all five lines above. A line whose probe fails is
408
543
  removed from the range rather than left claimed. The scratch tree's resolved
409
544
  versions are the ones to read back when a probe is quoted as evidence — the probe
410
545
  script pins them by exact version, and `--keep` leaves the tree in place to check.
411
546
 
547
+ The mode lookup stays guarded through this version too: the native-init family
548
+ needs the same resolved mode as the PTY family, and it takes it from the same
549
+ `ctx.get('sandboxPolicy')` capability guard, so a composition without the service
550
+ degrades to a disclosed silence rather than failing to load.
551
+
412
552
  ## Development
413
553
 
414
554
  ```sh
@@ -425,8 +565,9 @@ refuses a call that would have worked, is worse than one that stays silent.
425
565
  `test:inject` exists because an arm nobody has seen fail proves nothing. It
426
566
  mutates the decision layer one defect at a time — the mode gate removed, the
427
567
  PTY message matched as a substring, the preset remedy pointed back at the dead
428
- legacy directory, the two families collapsed into one bookkeeping slot, the
429
- advisory delivered per call instead of per agent — and requires that specific
568
+ legacy directory, two families collapsed into one bookkeeping slot, the advisory
569
+ delivered per call instead of per agent, the loader-status read moved onto the
570
+ error path, the family keyed on the rendered text — and requires that specific
430
571
  arms fail. It reports `SILENT ARMS: none` when every arm bites, restores the
431
572
  source in a `finally`, and prints `EQUIVALENT` (with the reason) for a mutation
432
573
  the current runtime cannot distinguish rather than counting it as a pass.
@@ -440,7 +581,12 @@ the current runtime cannot distinguish rather than counting it as a pass.
440
581
  [discussion #7771]: https://github.com/deepseek-ai/deepseek-harness/discussions/7771
441
582
  [discussion #7804]: https://github.com/deepseek-ai/deepseek-harness/discussions/7804
442
583
  [discussion #7816]: https://github.com/deepseek-ai/deepseek-harness/discussions/7816
584
+ [discussion #7638]: https://github.com/deepseek-ai/deepseek-harness/discussions/7638
585
+ [discussion #7876]: https://github.com/deepseek-ai/deepseek-harness/discussions/7876
586
+ [discussion #7877]: https://github.com/deepseek-ai/deepseek-harness/discussions/7877
443
587
  [#7750]: https://github.com/deepseek-ai/deepseek-harness/discussions/7750
444
588
  [#7771]: https://github.com/deepseek-ai/deepseek-harness/discussions/7771
445
589
  [#7804]: https://github.com/deepseek-ai/deepseek-harness/discussions/7804
446
- [discussion #7638]: https://github.com/deepseek-ai/deepseek-harness/discussions/7638
590
+ [#7876]: https://github.com/deepseek-ai/deepseek-harness/discussions/7876
591
+ [#7877]: https://github.com/deepseek-ai/deepseek-harness/discussions/7877
592
+ [#8193]: https://github.com/deepseek-ai/deepseek-harness/discussions/8193
package/lib/advice.js CHANGED
@@ -3,8 +3,9 @@
3
3
  * environment failure, and what is deliberately withheld.
4
4
  *
5
5
  * The text is assembled here as pure functions so every sentence can be pinned
6
- * by a test, one family at a time. The two families are shaped by the same two
7
- * questions, and they answer them differently:
6
+ * by a test, one family at a time. The three families are shaped by the same
7
+ * question — is this the sandbox's doing, and what can the reader do about it —
8
+ * and they answer it differently:
8
9
  *
9
10
  * - **The ACL failure** (`acl-provisioning`) *is* fixable by the caller, so its
10
11
  * advice names the right the caller is missing and gives the command.
@@ -45,6 +46,24 @@
45
46
  * Handing the model a command here would be advice to run something that
46
47
  * cannot run, and naming a one-shot shell tool would be advice to call a tool
47
48
  * the failing composition does not mount.
49
+ * - **The native-init death** (`native-init`) is the one whose remedy is **split**:
50
+ * the *class* is not the model's to fix, but one of its two measured producers
51
+ * is. A confined child that died with `STATUS_DLL_INIT_FAILED` never ran
52
+ * anything, so retrying the same call is pure waste — but if the program that
53
+ * could not start was an MSYS2/Git-Bash one, the same work expressed with
54
+ * PowerShell or `cmd` runs fine under the identical mode, and the model *can*
55
+ * make that change because the model is the one that wrote the command. So the
56
+ * advice carries a stop instruction, the one in-session conversion, and the
57
+ * user-side remedy for the other producer. What it deliberately does **not** do
58
+ * is guess which producer this is: the code alone cannot say, and the two
59
+ * checks it hands over are facts the reader holds (what program they ran;
60
+ * whether this is the packaged desktop app, which the plugin reports rather
61
+ * than assumes). That Electron host is itself **two measurements**, not one —
62
+ * a runner that never started at all, and a runner that did start and whose
63
+ * child cannot survive the restricted token derived from an Electron process
64
+ * image — and the advisory names both instead of asserting the one it shipped
65
+ * first, because a session cannot tell them apart and a confidently wrong
66
+ * cause is worse than two named ones with one shared remedy.
48
67
  *
49
68
  * Both give a **discriminator, not just a remedy**: applying a fix without
50
69
  * confirming the cause teaches nothing when the fix does not work. For the ACL
@@ -52,15 +71,23 @@
52
71
  * SID and grants `(F)` — which separates "Modify-only directory" from "the
53
72
  * documented prerequisite is wrong", the open question upstream. For the PTY
54
73
  * family it is the **effective sandbox mode**, which is why that advisory is
55
- * only ever built with the mode the call actually ran under.
74
+ * only ever built with the mode the call actually ran under. For the native-init
75
+ * family it is two checks the reader performs — which program could not start,
76
+ * and whether this host is the packaged desktop app — because the code alone
77
+ * cannot separate the producers and a guess would send half its readers to the
78
+ * wrong remedy. Inside that Electron answer there is a third thing the code
79
+ * cannot separate, and the advisory deliberately does not try: it names both
80
+ * measurements and says the remedy does not depend on choosing between them.
56
81
  *
57
82
  * @module
58
83
  */
59
- import { failureLine } from './signature.js';
84
+ import { failureLine, STATUS_DLL_INIT_FAILED } from './signature.js';
60
85
  /** The upstream threads the ACL advisory is a stopgap for. */
61
86
  export const ACL_DISCUSSIONS = '#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816';
62
87
  /** The upstream thread the persistent-shell advisory is a stopgap for. */
63
88
  export const PTY_DISCUSSIONS = '#7638';
89
+ /** The upstream threads the native-init-death advisory is a stopgap for. */
90
+ export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193';
64
91
  /** The documented prerequisite, quoted from the backend's README. */
65
92
  export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
66
93
  /**
@@ -99,6 +126,19 @@ export const NOT_FIXES = [
99
126
  ];
100
127
  /** Placeholder the user replaces with the directory the error named. */
101
128
  const PLACEHOLDER = '<the directory from the error line above>';
129
+ /**
130
+ * Whether this process is running on an Electron binary.
131
+ *
132
+ * In the packaged desktop the harness host *is* Electron, started with
133
+ * `ELECTRON_RUN_AS_NODE=1` so it behaves as Node — which is why the variable is
134
+ * defined here and why its presence is the discriminator the `#7876` producer
135
+ * turns on: `sandbox-local` launches the sandbox runner as `process.execPath`,
136
+ * and in that build the exec path is the Electron executable.
137
+ * @returns true when `process.versions.electron` is set.
138
+ */
139
+ function electronHost() {
140
+ return process.versions.electron !== undefined;
141
+ }
102
142
  /**
103
143
  * The diagnosis paragraph for one class of failure.
104
144
  * @param failure - the recognized failure.
@@ -197,11 +237,11 @@ function versionBoundary() {
197
237
  *
198
238
  * The family decides everything: one function so a caller does not have to
199
239
  * remember which family needs which fact, and so the mode requirement of the
200
- * PTY family is enforced by construction rather than by convention.
240
+ * two gated families is enforced by construction rather than by convention.
201
241
  * @param failure - the recognized failure.
202
242
  * @param context - what the caller knows about the failing call.
203
243
  * @returns the user-role notice text, with any remedy ready to paste.
204
- * @throws when a PTY failure is advised without its resolved sandbox mode.
244
+ * @throws when a mode-gated failure is advised without its resolved sandbox mode.
205
245
  */
206
246
  export function advisoryText(failure, context = {}) {
207
247
  if (failure.family === 'pty-startup') {
@@ -210,6 +250,12 @@ export function advisoryText(failure, context = {}) {
210
250
  }
211
251
  return ptyAdvisory(failure, context.mode, context.tool, context.href);
212
252
  }
253
+ if (failure.family === 'native-init') {
254
+ if (context.mode === undefined) {
255
+ throw new Error('sandbox-grant-advisor: the native-init advisory requires the resolved sandbox mode');
256
+ }
257
+ return nativeInitAdvisory(failure, context.mode, context.electronHost ?? electronHost(), context.href);
258
+ }
213
259
  return aclAdvisory(failure, context.href);
214
260
  }
215
261
  /**
@@ -275,6 +321,85 @@ function aclAdvisory(failure, href) {
275
321
  'Your file read/write tools still work; only sandboxed command execution is blocked.',
276
322
  ].join('\n');
277
323
  }
324
+ /**
325
+ * Build the advisory for a confined Windows child that never reached its entry
326
+ * point.
327
+ *
328
+ * Three things this text must not do: retry silently (the identical call cannot
329
+ * start), hand the model a command to run (there may be no working shell to run
330
+ * it in — that is what died), or assert which producer this is. The code is a
331
+ * loader status and says nothing about the sandbox by itself, so the diagnosis
332
+ * is the *class* ("the process never started") plus the two producers that have
333
+ * been measured under this harness, each with the check that distinguishes it.
334
+ * One of those checks the plugin answers itself and reports as a fact — whether
335
+ * this process is an Electron binary — rather than assuming, because a reader
336
+ * told "this is the packaged desktop" without evidence would be reading a guess
337
+ * dressed as a finding.
338
+ * @param failure - the recognized failure.
339
+ * @param mode - the resolved sandbox mode the failing call ran under.
340
+ * @param onElectron - whether this process is an Electron binary.
341
+ * @param href - optional URL shown for the upstream threads.
342
+ * @returns the user-role notice text.
343
+ */
344
+ function nativeInitAdvisory(failure, mode, onElectron, href) {
345
+ const where = href === undefined ? `tracked upstream (discussions ${NATIVE_INIT_DISCUSSIONS})` : `tracked upstream: ${href}`;
346
+ return [
347
+ 'Sandboxed command never started — the process died while its native libraries were loading.',
348
+ '',
349
+ 'What was reported:',
350
+ ` ${failureLine(failure)} (0xC0000142 STATUS_DLL_INIT_FAILED)`,
351
+ `The call ran under sandbox mode \`${mode}\`, where the harness starts every command through its`,
352
+ 'restricted-token runner.',
353
+ '',
354
+ `0x${failure.exitCode.toString(16).toUpperCase()} is STATUS_DLL_INIT_FAILED: the Windows loader terminated the process while it was`,
355
+ 'initializing its DLLs and C runtime, which is BEFORE the program\'s entry point. A command that ran and',
356
+ 'then failed exits with its own status and prints its own output; this one produced neither. The number',
357
+ 'is also not a portable exit status — those are 0-255, and this is a 32-bit NTSTATUS. Nothing in the code',
358
+ 'says "sandbox" by itself; what makes the sandbox a candidate is the mode above, under which every',
359
+ 'command is spawned through the ACL runner.',
360
+ '',
361
+ 'Two producers have been measured under a confining Windows mode. Check which one this is:',
362
+ ' 1. An MSYS2 / Git-Bash program — `bash.exe`, `sh.exe`, or anything from a Git for Windows or MSYS2',
363
+ ' distribution. Under the restricted token its runtime cannot create the pipe it uses for signals,',
364
+ ' and it aborts in the loader phase (`couldn\'t create signal pipe, Win32 error 5`), while `cmd.exe`',
365
+ ' and PowerShell run fine in the same workspace under the same mode (#7877).',
366
+ ' If that is what could not start: write the same work as a PowerShell or `cmd` command instead and',
367
+ ' continue — do not retry the MSYS2 program.',
368
+ ' 2. The packaged desktop application\'s sandbox runner. `dsh-sandbox-local` starts the runner as',
369
+ ' `[process.execPath, runner.js]`, and in the packaged build `process.execPath` is the Electron',
370
+ ' executable. Two measurements of that host exist and from inside a session they are',
371
+ ' indistinguishable, so both are named here rather than one asserted:',
372
+ ' (a) the runner does not start at all: the Electron binary begins as an *application* unless the',
373
+ ' child\'s environment carries `ELECTRON_RUN_AS_NODE=1`, so nothing on the runner path ran (#7876);',
374
+ ' (b) the runner does start — the desktop launcher sets exactly that variable — and the command still',
375
+ ' dies, because the restricted token is derived from the Electron process image and the child does',
376
+ ' not survive being started under it (#8193).',
377
+ ' A reader outside the session separates them (is `ELECTRON_RUN_AS_NODE` set for that host, and does',
378
+ ' the ACL runner appear among its processes while the command runs?); from inside the session they are',
379
+ ' one finding with one remedy, so nothing here waits on telling them apart.',
380
+ onElectron
381
+ ? ' This process IS an Electron binary (`process.versions.electron` is set), so that producer applies here:'
382
+ : ' This process is NOT an Electron binary (`process.versions.electron` is unset), so the runner is a real',
383
+ onElectron
384
+ ? ' run the same command with `danger-full-access`, or from an unpacked `node apps/cli/lib/bin.js web`'
385
+ : ' Node binary here and that producer cannot be the cause. If the program was not an MSYS2 one either,',
386
+ onElectron
387
+ ? ' host where the runner is a real Node binary. The command starts there in either shape, and that — the host, not the flags — is the discriminator.'
388
+ : ' this failure is outside both measured producers: stop and hand it to the user.',
389
+ '',
390
+ 'Do not retry this call: the environment has not changed, and the identical call produces the identical',
391
+ 'code. Convert the work only in case 1; otherwise stop and hand it to the user.',
392
+ '',
393
+ 'Honest boundary — 0xC0000142 has producers this list does not have: a program that cannot load one of',
394
+ 'its own DLLs dies this way too, and the sandbox backend\'s own source records that a child started with',
395
+ 'a hidden console window does as well (which is why that backend avoids `CREATE_NO_WINDOW`). This is not',
396
+ 'a claim that the sandbox caused the failure — the code cannot say that. What is claimed is narrower and',
397
+ 'checkable: the process never reached its entry point, and under this mode these two producers are known.',
398
+ '',
399
+ 'This is a stopgap, ' + where + '. What it is NOT: this plugin neither changes an environment nor',
400
+ 'widens the sandbox — the checks above are yours to make, and `danger-full-access` is not offered as a fix.',
401
+ ].join('\n');
402
+ }
278
403
  /**
279
404
  * Build the advisory for a persistent-shell startup failure.
280
405
  *
@@ -328,14 +453,18 @@ function ptyAdvisory(failure, mode, tool, href) {
328
453
  * Build the pre-dispatch denial for the optional fail-fast half.
329
454
  *
330
455
  * The blocking half is deliberately **ACL-only**, and this function's parameter
331
- * type is where that is enforced. The PTY family gets an advisory and nothing
332
- * else, for a reason that is about the remedy rather than about the failure:
456
+ * type is where that is enforced. The two mode-gated families get an advisory
457
+ * and nothing else, for a reason that is about the remedy rather than about the
458
+ * failure:
333
459
  * the ACL remedy is a command the user can run *while the session continues*,
334
460
  * so refusing further identical calls cannot make the session unfinishable —
335
461
  * spending the budget always lets the call through, and a repaired environment
336
462
  * is discovered by exactly that. The PTY remedy is a preset swap, which happens
337
- * between sessions; refusing calls could only pad a session that is already
338
- * unable to do the thing being refused.
463
+ * between sessions, and the native-init remedy is a launch fix on the user's
464
+ * side (the one in-session part — rewriting an MSYS2 command — the model does by
465
+ * calling a different tool invocation, which has a different call key and is
466
+ * therefore never the call being refused); refusing calls could only pad a
467
+ * session that is already unable to do the thing being refused.
339
468
  * @param failure - the recognized failure.
340
469
  * @param observed - how many provisioning failures this agent has produced.
341
470
  * @param denial - this denial's 1-based ordinal.