@argszero/cordis-plugin-sandbox-grant-advisor 0.7.1 → 0.8.1

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
@@ -35,12 +35,24 @@ stuck.
35
35
 
36
36
  ### 1. Workspace provisioning — the Windows ACL failure (`acl-provisioning`)
37
37
 
38
- Six reports describe this exact line: [discussion #7538], [discussion #7622],
39
- [discussion #7646], [discussion #7720], [discussion #7750], [discussion #7735]
40
- (the last two on data-volume workspaces, where *no* ACE names the caller at all —
41
- the inherited `Authenticated Users: Modify` is the whole of their access). In each
42
- one every sandboxed command fails the same way, before it runs, and the error names
43
- neither the missing right nor a remedy.
38
+ Seven reports describe this exact line: [discussion #7538], [discussion #7622],
39
+ [discussion #7646], [discussion #7720], [discussion #7750], [discussion #7735] and
40
+ [discussion #8232] (the last three on data-volume workspaces, where *no* ACE names
41
+ the caller at all — the inherited `Authenticated Users: Modify` is the whole of
42
+ their access). In each one every sandboxed command fails the same way, before it
43
+ runs, and the error names neither the missing right nor a remedy.
44
+
45
+ `#8232` contributes two facts about the *shape* of the failure rather than its
46
+ cause, and both are in the advisory now. One is that the failure belongs to the
47
+ **workspace, not the command**: there a `Get-Date` failed exactly like anything
48
+ that writes, because the grant is materialized before the command runs at all. The
49
+ other is the **scope of the repair**: the `(OI)(CI)(WO)` line covers *this*
50
+ directory and its children, so a second workspace root on the same volume is a
51
+ sibling rather than a child and needs the same line once more — which is why that
52
+ reporter saw a second workspace fail on a machine whose first one was already
53
+ repaired. The report also reaches the same root cause on its own (the merged write
54
+ wanting `WRITE_OWNER`, which owner-implicit rights do not carry), matching the
55
+ backend's documented prerequisite.
44
56
 
45
57
  `#7720` is worth reading for where the failure lands: the grant is materialized
46
58
  at sandbox **initialization**, so this is not one refused operation but *every*
@@ -93,6 +105,13 @@ Two consequences follow from that one line:
93
105
  what confines deletes to the workspace, and reverting it reintroduces the
94
106
  escape it closed. `#7750` asks for exactly this and explains why it is a
95
107
  usability regression traded for a security fix.
108
+ 5. **The repair is per-directory, and the report is what established that.** `#8232`
109
+ applied the `(WO)` line, watched the workspace start working, and then hit the
110
+ same error on a *second* workspace root on the same volume. `(OI)(CI)` carries
111
+ the ACE into *children* of the directory that received it and nowhere else, so a
112
+ sibling root is untouched by it. One line per workspace root is therefore the
113
+ correct shape of the remedy, not one line per machine — and the advisory says so,
114
+ because the natural reading of "it worked" is "it is fixed".
96
115
 
97
116
  The grant is materialized lazily, on the first confined call, and **nothing is
98
117
  cached when it throws** — so the same failure repeats per command (850 calls
@@ -236,8 +255,9 @@ the advisory never names the dead directory.
236
255
 
237
256
  ### 3. A confined child that never started (`native-init`)
238
257
 
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`
258
+ Four reports of one exit code: [`#7876`] and [`#8193`] (the packaged desktop
259
+ app) and [`#7877`] (MSYS2 / Git Bash) — and [`#8208`], which found the mechanism
260
+ the console cases share. All are `0xC0000142`
241
261
  `STATUS_DLL_INIT_FAILED` — the Windows
242
262
  loader terminated the process while it was initializing its native images, i.e.
243
263
  **before the program's entry point**. A command that ran and then failed exits
@@ -259,65 +279,101 @@ Two producers have been measured under a confining mode:
259
279
  (`@deepseek-ai/dsh-base/cordis.patch.yml`), so the combination is likely
260
280
  never covered upstream. The **one conversion a model can make itself** is to
261
281
  write the same work as a PowerShell or `cmd` command.
262
- 2. **The packaged desktop app's sandbox runner** ([`#7876`], [`#8193`]).
282
+ 2. **The packaged desktop app's sandbox runner** ([`#8193`], [`#8208`]).
263
283
  `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
- **The remedy is the host, and 0.7.1 says so in that order.** [`#8193`] did not
285
- only split the producer — it measured the way out: hosted on the desktop's own
286
- bundled standalone node
284
+ in the packaged build `process.execPath` is the Electron executable. **The
285
+ mechanism is the runner's console, not its token** ([`#8208`]): the confined
286
+ child inherits a console from the runner, and a runner that owns none leaves
287
+ the child to ask for one of its own — which a restricted token may not have.
288
+ The capture is three lines: `conhost.exe` is created *by the restricted child*,
289
+ exits `0xC0000022 STATUS_ACCESS_DENIED`, and the child then dies with
290
+ `0xC0000142`. Two console-less configurations are measured, and they share that
291
+ mechanism: **(a)** the host binary is a GUI-subsystem program, which never owns
292
+ a console — the packaged desktop, where every confined command dies this way
293
+ ([`#8193`]); **(b)** the host binary is a real console-subsystem `node.exe` and
294
+ the runner was still spawned without a console, because `DETACHED_PROCESS` was
295
+ set — `spawnSync(node, [runner, …], { detached: true })` returns `0xC0000142`
296
+ where the identical call without that flag returns `0` ([`#8208`]). Shape (b) is
297
+ the one worth handing over, because it needs no desktop and no particular
298
+ machine. **What is *not* the discriminator is the token**: [`#8208`] compared
299
+ `whoami /groups` and `/priv` from children of a working node host and of the
300
+ failing Electron host and found them identical, and a low-integrity `cmd.exe`
301
+ runs fine on that machine. The plugin reports whether *this* process is an
302
+ Electron binary (`process.versions.electron`) as a measured fact rather than
303
+ assuming it.
304
+
305
+ **`0.7.x` named two shapes and explained them with the wrong mechanism, and
306
+ `0.8.0` withdraws one of the shapes outright.** The withdrawn shape is "the
307
+ runner does not start at all, because `ELECTRON_RUN_AS_NODE=1` is missing": the
308
+ desktop sets that variable on its own host child
309
+ (`apps/desktop/src/host-process.ts` → `desktopNodeEnvironment()`,
310
+ `apps/desktop/src/node-environment.ts:15`), so nothing on the runner path can
311
+ fail to start for want of it — and [`#8208`] measured the variable present in
312
+ *both* hosts, including the one that works. The token story goes with it, for
313
+ the same measurement. A cause the advisory shipped twice earns its sentence
314
+ when it is retracted, and test arms keep both retractions in place.
315
+
316
+ **The remedy is a real node host — with one condition riding on it.** [`#8193`]
317
+ measured the way out: hosted on the desktop's own bundled standalone node
287
318
  (`resources/runtime/primary-runtime/dependencies/node/bin/node.exe`, v24.21.0)
288
319
  the *same* confined `pwsh.exe` / `cmd.exe` spawns succeed (exit 81 / 82), with
289
320
  workspace, temp directory, mode, SIDs, target and runner `sha256` all held
290
- constant. The advisory now leads the Electron branch with that, and demotes
291
- `danger-full-access` to what it actually is — a way to **confirm** the
292
- diagnosis, not a fix, and on this platform one that silently removes the
293
- sandbox from every shell call. The reporter's own reason is the one the plugin
294
- repeats: *"the practical effect is that Windows Desktop users must escalate to
295
- full access for all shell work, which silently removes the sandbox on that
296
- platform"*, and they asked explicitly that this not be "fixed" with
297
- `--disable-sandbox` / `--disable-gpu-sandbox` — those disable Chromium's
298
- renderer sandbox, a different layer from the DSH file policy. The advisory also
299
- warns off the opposite-looking move: unsetting `ELECTRON_RUN_AS_NODE` does not
300
- help, because the desktop's runner **is** that Electron binary and without the
301
- variable it cannot execute `runner.js` at all — the interaction [`#8193`]
302
- records with [`#8174`], where a fix that tombstones the variable in the shared
303
- child environment would take the ACL runner down with it, so the two changes
304
- have to land together.
321
+ constant. [`#8208`] reproduced it (exit `0`, the command's own stdout intact)
322
+ on the same runtime installed for workspace dependencies
323
+ (`%USERPROFILE%\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe`,
324
+ present once `load_workspace_dependencies` has run) and with `windowsHide` both
325
+ set and unset. **The condition is that the runner must be spawned *with* a
326
+ console** — not with `DETACHED_PROCESS` — because a real host alone is not
327
+ enough when that flag is set ([`#8208`]): the host binary supplies the console
328
+ and the spawn flag is what can take it away again. Where no real host can be
329
+ put in front of the runner, the same effect fits inside it: `AllocConsole`
330
+ before the restricted spawn, with the three standard handles restored
331
+ afterwards, in `dsh-win32-process`'s `createRestrictedProcess` — the funnel
332
+ every restricted child goes through. [`#8208`] measured `cmd /c exit` and
333
+ `pwsh -c "Write-Output …"` both reaching `0` with stdout intact under that
334
+ change, and as a no-op on a runner that already owns a console; it costs a
335
+ `user32` binding and one console host per runner process. `danger-full-access`
336
+ is demoted to what it actually is — a way to **confirm** the diagnosis, not a
337
+ fix, and on this platform one that silently removes the sandbox from every
338
+ shell call. The reporter's own reason is the one the plugin repeats: *"the
339
+ practical effect is that Windows Desktop users must escalate to full access for
340
+ all shell work, which silently removes the sandbox on that platform"*, and they
341
+ asked explicitly that this not be "fixed" with `--disable-sandbox` /
342
+ `--disable-gpu-sandbox` — those disable Chromium's renderer sandbox, a
343
+ different layer from the DSH file policy. The advisory also warns off the
344
+ opposite-looking move: unsetting `ELECTRON_RUN_AS_NODE` does not help, because
345
+ the desktop's runner **is** that Electron binary and dropping the variable
346
+ would take `runner.js` down with it — the interaction [`#8193`] records with
347
+ [`#8174`], where a fix that tombstones the variable in the shared child
348
+ environment would take the ACL runner down with it, so the two changes have to
349
+ land together.
305
350
 
306
351
  **What the flag vocabulary actually is.** The backend's own source records one
307
352
  inherent boundary: *"console isolation is unavailable — children share the host
308
353
  console (`CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` children die with
309
354
  `STATUS_DLL_INIT_FAILED` under the restriction)"*
310
- (`packages/sandbox/sandbox-windows-acl/src/index.ts`), echoed at
311
- `packages/subprocess/win32-process/src/process.ts:454`. [`#8193`] refines that
312
- recording instead of repeating it, and the refinement is worth keeping exact:
313
- the restricted-token path spawns with **`CREATE_SUSPENDED` alone**
314
- (`process.ts:537-545`) and the ordinary current-token path with
315
- **`CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT`** (`process.ts:567`, i.e.
316
- `0x404`); `CREATE_NO_WINDOW` (`0x08000000`) is **not a constant anywhere in
317
- that source** — only those two comments name it at all. So the flags in use are
318
- a necessary-but-insufficient ingredient: the same flags that are fatal under an
319
- Electron host succeed under a real-node host, and the host process image is
320
- what turns them fatal.
355
+ (`packages/sandbox/sandbox-windows-acl/README.md:119`), echoed at
356
+ `packages/subprocess/win32-process/src/process.ts:454`. [`#8208`] **explains**
357
+ that recording instead of repeating it, and the explanation is the invariant
358
+ the two shapes share: **in a restricted token a console can be inherited but
359
+ not created.** The creation flags actually passed are three sets and none of
360
+ them is `CREATE_NO_WINDOW` — `0` on the piped path (`process.ts:243`, the path a
361
+ shell call takes), `CREATE_SUSPENDED` on the inherited-job path
362
+ (`process.ts:542`) and `CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT` on the
363
+ ordinary path (`process.ts:567`, i.e. `0x404`); `CREATE_NO_WINDOW`
364
+ (`0x08000000`) is **not a constant anywhere in that source**. Those flags are
365
+ fatal under a console-less runner and harmless under one that owns a console —
366
+ so it is the console, not the flag list, that decides. `0.7.x` wrote the
367
+ two-set version of that list and called the flags "a necessary ingredient ...
368
+ the host process image is what turns it fatal"; both halves were wrong, and
369
+ `0.8.0` replaces them.
370
+
371
+ **One arm was applied, measured, and rejected.** Putting `DETACHED_PROCESS` on
372
+ the *restricted child* removes its console request and does stop the crash —
373
+ but `pwsh` then exits `0` with **zero bytes on stdout and stderr**, while
374
+ `cmd.exe` keeps its output ([`#8208`]). That trades a loud failure for a silent
375
+ one on the interpreter most likely to be used, so the advisory names it as a
376
+ non-remedy instead of offering it; the runner-side remedies keep the output.
321
377
 
322
378
  **Why this family is read from a successful result.** The producer never marks
323
379
  it an error, and that is a fact about upstream rather than a choice here:
@@ -619,13 +675,17 @@ the current runtime cannot distinguish rather than counting it as a pass.
619
675
  [discussion #7771]: https://github.com/deepseek-ai/deepseek-harness/discussions/7771
620
676
  [discussion #7804]: https://github.com/deepseek-ai/deepseek-harness/discussions/7804
621
677
  [discussion #7816]: https://github.com/deepseek-ai/deepseek-harness/discussions/7816
678
+ [discussion #8232]: https://github.com/deepseek-ai/deepseek-harness/discussions/8232
622
679
  [discussion #7638]: https://github.com/deepseek-ai/deepseek-harness/discussions/7638
623
680
  [discussion #7876]: https://github.com/deepseek-ai/deepseek-harness/discussions/7876
624
681
  [discussion #7877]: https://github.com/deepseek-ai/deepseek-harness/discussions/7877
682
+ [discussion #8208]: https://github.com/deepseek-ai/deepseek-harness/discussions/8208
625
683
  [#7750]: https://github.com/deepseek-ai/deepseek-harness/discussions/7750
626
684
  [#7771]: https://github.com/deepseek-ai/deepseek-harness/discussions/7771
627
685
  [#7804]: https://github.com/deepseek-ai/deepseek-harness/discussions/7804
628
686
  [#7876]: https://github.com/deepseek-ai/deepseek-harness/discussions/7876
629
687
  [#7877]: https://github.com/deepseek-ai/deepseek-harness/discussions/7877
688
+ [#8232]: https://github.com/deepseek-ai/deepseek-harness/discussions/8232
630
689
  [#8193]: https://github.com/deepseek-ai/deepseek-harness/discussions/8193
631
690
  [#8174]: https://github.com/deepseek-ai/deepseek-harness/discussions/8174
691
+ [#8208]: https://github.com/deepseek-ai/deepseek-harness/discussions/8208
package/lib/advice.js CHANGED
@@ -58,12 +58,21 @@
58
58
  * is guess which producer this is: the code alone cannot say, and the two
59
59
  * checks it hands over are facts the reader holds (what program they ran;
60
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.
61
+ * than assumes). That Electron host was shipped as **two measurements**; since
62
+ * 0.8.0 it is **one mechanism, named**: the runner must own a *console* for the
63
+ * confined child to inherit, and when it owns none the child's own console
64
+ * request is denied under the restricted token (`#8208` traced it to
65
+ * `conhost.exe` created by the restricted child, exiting `STATUS_ACCESS_DENIED`).
66
+ * The two measured console-less shapes are a GUI-subsystem host image (the
67
+ * packaged desktop) and a `DETACHED_PROCESS` runner under a real `node.exe`
68
+ * host — the second reproducible on any machine, which is what makes it the
69
+ * check worth handing over. The shape 0.7.x led with, "the runner never started
70
+ * at all", is **withdrawn**: the desktop's own host child is started with
71
+ * `ELECTRON_RUN_AS_NODE=1` (`apps/desktop/src/host-process.ts` ->
72
+ * `desktopNodeEnvironment()`), so nothing on the runner path fails to start for
73
+ * want of that variable. A withdrawn cause earns its sentence because this
74
+ * advisory shipped it twice; a confidently wrong cause is worse than two named
75
+ * ones with one shared remedy.
67
76
  *
68
77
  * Both give a **discriminator, not just a remedy**: applying a fix without
69
78
  * confirming the cause teaches nothing when the fix does not work. For the ACL
@@ -83,11 +92,11 @@
83
92
  */
84
93
  import { failureLine, STATUS_DLL_INIT_FAILED } from './signature.js';
85
94
  /** The upstream threads the ACL advisory is a stopgap for. */
86
- export const ACL_DISCUSSIONS = '#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816';
95
+ export const ACL_DISCUSSIONS = '#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816 / #8232';
87
96
  /** The upstream thread the persistent-shell advisory is a stopgap for. */
88
97
  export const PTY_DISCUSSIONS = '#7638';
89
98
  /** The upstream threads the native-init-death advisory is a stopgap for. */
90
- export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193';
99
+ export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193 / #8208';
91
100
  /** The documented prerequisite, quoted from the backend's README. */
92
101
  export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
93
102
  /**
@@ -269,6 +278,8 @@ function aclAdvisory(failure, href) {
269
278
  const where = href === undefined ? `tracked upstream (discussions ${ACL_DISCUSSIONS})` : `tracked upstream: ${href}`;
270
279
  return [
271
280
  'Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.',
281
+ 'The failure belongs to the WORKSPACE, not to the command: under this mode a command that only reads',
282
+ 'fails identically, so trying a different or more harmless command is not a retry that can succeed.',
272
283
  '',
273
284
  'What was reported:',
274
285
  ` ${failureLine(failure)}`,
@@ -293,6 +304,9 @@ function aclAdvisory(failure, href) {
293
304
  ` cmd: icacls "${path}" /grant "%USERNAME%:(OI)(CI)(WO)"`,
294
305
  'WRITE_OWNER is exactly the right the prerequisite names, so this grants nothing the harness did not ask for,',
295
306
  'and (OI)(CI) makes the ACE inheritable, so one command reaches the workspace\'s existing subdirectories.',
307
+ 'The grant is still scoped to THIS directory and its children: another workspace root on the same volume is',
308
+ 'a sibling rather than a child, so it needs the same line once — which is why a second workspace fails on a',
309
+ 'machine where the first one was already repaired.',
296
310
  'Full control works just as well — the same line with `F` in place of `(WO)`:',
297
311
  ` icacls "${path}" /grant "$env:USERNAME:(OI)(CI)F"`,
298
312
  'Why `(WO)` is the whole of what is missing there: an owner holds READ_CONTROL and WRITE_DAC implicitly,',
@@ -367,46 +381,76 @@ function nativeInitAdvisory(failure, mode, onElectron, href) {
367
381
  ' continue — do not retry the MSYS2 program.',
368
382
  ' 2. The packaged desktop application\'s sandbox runner. `dsh-sandbox-local` starts the runner as',
369
383
  ' `[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.',
384
+ ' executable. The mechanism behind this one is the runner\'s CONSOLE, not its token: the confined',
385
+ ' child inherits a console from the runner, and a runner that owns none leaves the child to ask for one',
386
+ ' of its own — which a restricted token is not allowed to have. #8208 captured the sequence: `conhost.exe`',
387
+ ' is created BY the restricted child, exits `0xC0000022 STATUS_ACCESS_DENIED`, and the child then dies',
388
+ ' with the code above. Both console-less configurations are measured:',
389
+ ' (a) the host binary is a GUI-subsystem program, which never owns a console — the packaged desktop,',
390
+ ' where every confined command dies this way (#8193);',
391
+ ' (b) the host binary is a real console-subsystem `node.exe` and the runner was still spawned without a',
392
+ ' console, because `DETACHED_PROCESS` was set: `spawnSync(node, [runner, …], { detached: true })`',
393
+ ' returns `0xC0000142` while the same call without that flag returns `0` (#8208). That arm needs no',
394
+ ' desktop and no particular machine, so it is the check worth running here.',
395
+ ' What is NOT the discriminator: the token. #8208 compared `whoami /groups` and `/priv` from children of',
396
+ ' a working node host and of the failing Electron host — identical, down to the group count and session —',
397
+ ' and a low-integrity `cmd.exe` runs fine on that machine, so neither the token nor low integrity alone',
398
+ ' explains this.',
380
399
  ...(onElectron
381
400
  ? [
382
- ' This process IS an Electron binary (`process.versions.electron` is set), so that producer applies here.',
383
- ' The way out is a real node host, not a wider mode: the packaged desktop ships one at',
384
- ' `resources/runtime/primary-runtime/dependencies/node/bin/node.exe`, and the unpacked',
385
- ' `node apps/cli/lib/bin.js web` host works for the same reason — the same confined `pwsh`/`cmd` calls',
386
- ' start there (#8193 measured exit 81/82). The command starts there in either shape, and that — the',
387
- ' host, not the flags — is the discriminator. `danger-full-access` only CONFIRMS the diagnosis: on this',
388
- ' platform it silently removes the sandbox from every shell call. Do not unset `ELECTRON_RUN_AS_NODE`',
389
- ' instead either — the desktop runner IS that Electron binary, so without it `runner.js` cannot',
390
- ' execute at all (#8193 records this interaction with #8174).',
401
+ ' This process IS an Electron binary (`process.versions.electron` is set), so a GUI-subsystem host',
402
+ ' applies here. The way out is a real node host — the packaged desktop ships one at',
403
+ ' `resources/runtime/primary-runtime/dependencies/node/bin/node.exe`, and the same runtime installed',
404
+ ' for workspace dependencies lands at',
405
+ ' `%USERPROFILE%\\.dsh\\dsh-runtimes\\dsh-primary-runtime\\dependencies\\node\\bin\\node.exe` (present',
406
+ ' once `load_workspace_dependencies` has run) — and the unpacked `node apps/cli/lib/bin.js web` host',
407
+ ' works for the same reason: the same confined `pwsh`/`cmd` calls start there (#8193 measured exit',
408
+ ' 81/82; #8208 measured exit 0 with the command\'s own stdout intact, with and without `windowsHide`).',
409
+ ' ONE CONDITION RIDES WITH THAT: the runner must be spawned WITH a console, i.e. not with',
410
+ ' `DETACHED_PROCESS`. A real host alone is not enough if that flag is set (#8208) — the host binary is',
411
+ ' what supplies the console, and the spawn flag is what can take it away again.',
412
+ ' If no real node host can be put in front of the runner, the same effect fits inside it: `AllocConsole`',
413
+ ' before the restricted spawn, with the three standard handles restored afterwards, in',
414
+ ' `dsh-win32-process`\'s `createRestrictedProcess` — the funnel every restricted child goes through.',
415
+ ' #8208 measured `cmd /c exit` and `pwsh -c "Write-Output …"` both reaching 0 with stdout intact under',
416
+ ' that change, and as a no-op on a runner that already owns a console; it costs a `user32` binding and',
417
+ ' one console host per runner process.',
418
+ ' `danger-full-access` only CONFIRMS the diagnosis: on this platform it silently removes the sandbox',
419
+ ' from every shell call. Do not unset `ELECTRON_RUN_AS_NODE` instead either — the desktop runner IS',
420
+ ' that Electron binary, so dropping the variable would take `runner.js` down with it (#8193 records',
421
+ ' this interaction with #8174).',
391
422
  ]
392
423
  : [
393
424
  ' This process is NOT an Electron binary (`process.versions.electron` is unset), so the runner is a real',
394
- ' Node binary here and that producer cannot be the cause. If the program was not an MSYS2 one either,',
395
- ' this failure is outside both measured producers: stop and hand it to the user.',
425
+ ' Node binary here and a GUI-subsystem host cannot be the cause. One measured shape is still open: a',
426
+ ' runner that owns no console because it was spawned with `DETACHED_PROCESS` fails exactly this way on a',
427
+ ' real node host too (#8208) — that is a property of how this host was launched, not of the build. If the',
428
+ ' program was not an MSYS2 one either, and the runner was not spawned detached, this failure is outside',
429
+ ' both measured producers: stop and hand it to the user.',
396
430
  ]),
397
431
  '',
398
432
  'Do not retry this call: the environment has not changed, and the identical call produces the identical',
399
433
  'code. Convert the work only in case 1; otherwise stop and hand it to the user.',
400
434
  '',
401
435
  'Honest boundary — 0xC0000142 has producers this list does not have: a program that cannot load one of',
402
- 'its own DLLs dies this way too, and the backend\'s own source records that a child created with a hidden',
403
- 'console window can as well (`CREATE_NO_WINDOW` can fail restricted-token DLL initialization). #8193',
404
- 'refines that recording rather than repeating it: `CREATE_NO_WINDOW` is not among the flags actually passed',
405
- '(they are `CREATE_SUSPENDED`, and `CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT` on the unrestricted',
406
- 'path), and the same flags succeed under a real-node host — so the flag is a necessary ingredient and the',
407
- 'host process image is what turns it fatal. This is not a claim that the sandbox caused the failure — the',
408
- 'code cannot say that. What is claimed is narrower and checkable: the process never reached its entry',
409
- 'point, and under this mode these two producers are known.',
436
+ 'its own DLLs dies this way too, and the backend\'s own source records the console case as an inherent',
437
+ 'limit of the backend (`CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` children die with `STATUS_DLL_INIT_FAILED`',
438
+ 'under the restriction). #8208 explains that limit instead of repeating it, and the explanation is what the',
439
+ 'two shapes above share: in a restricted token a console can be INHERITED but not CREATED. The creation',
440
+ 'flags actually passed are three sets and none of them is `CREATE_NO_WINDOW` — `0` on the piped path,',
441
+ '`CREATE_SUSPENDED` on the inherited-job path, and `CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT` on the',
442
+ 'ordinary path. Those same flags are fatal under a console-less runner and harmless under one that owns a',
443
+ 'console, so it is the console and not the flag list that decides.',
444
+ '',
445
+ 'One arm of this family was applied, measured, and rejected — named so a reader does not reach for it:',
446
+ 'putting `DETACHED_PROCESS` on the RESTRICTED CHILD removes its console request and does stop the crash,',
447
+ 'but `pwsh` then exits 0 with zero bytes on stdout AND stderr, while `cmd.exe` keeps its output (#8208).',
448
+ 'That trades a loud failure for a silent one on the interpreter most likely to be used, so the runner-side',
449
+ 'remedies above — which keep the output — are the ones to take.',
450
+ '',
451
+ 'This is not a claim that the sandbox caused the failure — the code cannot say that. What is claimed is',
452
+ 'narrower and checkable: the process never reached its entry point, and under this mode these producers are',
453
+ 'known.',
410
454
  '',
411
455
  'This is a stopgap, ' + where + '. What it is NOT: this plugin neither changes an environment nor',
412
456
  'widens the sandbox — the checks above are yours to make, and `danger-full-access` is not offered as a fix.',
@@ -58,12 +58,21 @@
58
58
  * is guess which producer this is: the code alone cannot say, and the two
59
59
  * checks it hands over are facts the reader holds (what program they ran;
60
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.
61
+ * than assumes). That Electron host was shipped as **two measurements**; since
62
+ * 0.8.0 it is **one mechanism, named**: the runner must own a *console* for the
63
+ * confined child to inherit, and when it owns none the child's own console
64
+ * request is denied under the restricted token (`#8208` traced it to
65
+ * `conhost.exe` created by the restricted child, exiting `STATUS_ACCESS_DENIED`).
66
+ * The two measured console-less shapes are a GUI-subsystem host image (the
67
+ * packaged desktop) and a `DETACHED_PROCESS` runner under a real `node.exe`
68
+ * host — the second reproducible on any machine, which is what makes it the
69
+ * check worth handing over. The shape 0.7.x led with, "the runner never started
70
+ * at all", is **withdrawn**: the desktop's own host child is started with
71
+ * `ELECTRON_RUN_AS_NODE=1` (`apps/desktop/src/host-process.ts` ->
72
+ * `desktopNodeEnvironment()`), so nothing on the runner path fails to start for
73
+ * want of that variable. A withdrawn cause earns its sentence because this
74
+ * advisory shipped it twice; a confidently wrong cause is worse than two named
75
+ * ones with one shared remedy.
67
76
  *
68
77
  * Both give a **discriminator, not just a remedy**: applying a fix without
69
78
  * confirming the cause teaches nothing when the fix does not work. For the ACL
@@ -84,11 +93,11 @@
84
93
  import type { ProvisioningFailure, RecognizedFailure } from './signature.js';
85
94
  import type { SandboxModeName } from './mode.js';
86
95
  /** The upstream threads the ACL advisory is a stopgap for. */
87
- export declare const ACL_DISCUSSIONS = "#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816";
96
+ export declare const ACL_DISCUSSIONS = "#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816 / #8232";
88
97
  /** The upstream thread the persistent-shell advisory is a stopgap for. */
89
98
  export declare const PTY_DISCUSSIONS = "#7638";
90
99
  /** The upstream threads the native-init-death advisory is a stopgap for. */
91
- export declare const NATIVE_INIT_DISCUSSIONS = "#7876 / #7877 / #8193";
100
+ export declare const NATIVE_INIT_DISCUSSIONS = "#7876 / #7877 / #8193 / #8208";
92
101
  /** The documented prerequisite, quoted from the backend's README. */
93
102
  export declare const PREREQUISITE = "granted directories must be caller-owned and grant `WRITE_OWNER`";
94
103
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@argszero/cordis-plugin-sandbox-grant-advisor",
3
- "description": "Turns three sandbox environment failures that name neither their cause nor a remedy into a diagnosis with a path forward. Family 1, the Windows workspace ACL: nine reports (#7538, #7622, #7646, #7720, #7750, #7735, #7771, #7804, #7816) of one signature \u2014 every sandboxed command fails before it runs with `SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace>)`, because the merged DACL + mandatory-label write needs WRITE_OWNER on the directory (an object right the caller can self-grant), not SeSecurityPrivilege and not elevation; the host grant is materialized lazily and caches nothing on the failure path, so the same failure repeats per command. Family 2, the persistent shell (#7638): with the `minimal` preset under a confining sandbox mode every shell call dies instantly with `PTY shell exited during startup` because the terminal backend cannot create the pseudo-console inside the sandbox, retrying never helps, and `minimal` mounts no fallback shell tool. Family 3, a confined Windows child that died during native initialization (#7876, #7877): every command spawned through the sandbox runner can report exit 0xC0000142 STATUS_DLL_INIT_FAILED with the process never reaching its entry point \u2014 the packaged desktop starts that runner as [process.execPath, entry], and that host has been measured twice with one indistinguishable appearance from inside a session \u2014 either nothing on the runner path ran because the Electron binary launches as an application unless the child's environment carries ELECTRON_RUN_AS_NODE=1 (#7876), or the desktop launcher does set that variable and the child still dies because the restricted token is derived from the Electron process image (#8193), and an MSYS2 / Git-Bash program cannot create its signal pipe under the restricted token while cmd.exe and pwsh run fine in the same workspace under the same mode (#7877) \u2014 and because upstream's runner-failure rules admit only exit 127 with the `windows-acl-run: ` signature, the code is never an error: it arrives as the canonical value of a result the pipeline calls a success. The plugin observes the public `tools/post-execute` waterfall, classifies all three signatures narrowly (only the two `...NamedSecurityInfoW` operations; the PTY message matched on a whole line, never as a substring; the loader status read as a 32-bit integer out of the shell tool's own canonical success value, never from rendered text; both mode-gated families advised only under a mode the policy resolver reports as confining), and attaches ONE durable user-role advisory per agent per family through `additionalContexts`. The ACL advisory names the missing right, both environments the identical text can describe (an inherited Modify-only entry, a data volume where no ACE names the caller at all, and a directory owned by another account), the version boundary that arrived with the mandatory label (0.1.7-alpha.1, flag 20, versus the DACL-only flag 4 up to 0.1.6-alpha.x) together with why downgrading is not the remedy, the discriminator, the remedy forked on an ownership check the user runs (`(Get-Acl \"<dir>\").Owner`), because one command cannot serve both rights situations: where the caller owns the directory, the unelevated `icacls ... :(OI)(CI)(WO)` is the whole of what is missing \u2014 the owner's implicit WRITE_DAC already covers the DACL half \u2014 and where the caller does not own it that same command is refused for want of WRITE_DAC, so the grant has to come from an elevated account, or by taking ownership first, or by moving the workspace under %USERPROFILE% \u2014 and the two remedies that look right and are not (`takeown`, `icacls /reset`), each with the reason it fails; the PTY advisory names the failing combination, states the resolved mode, tells the model to stop rather than retry, and hands the user-side preset choice over \u2014 it never names a shell tool the failing composition does not mount; the native-init advisory states the resolved mode, says the process died before its entry point, enumerates the two producers measured under a confining mode \u2014 naming both measured shapes of the desktop host rather than asserting the one that was measured first \u2014 with the check that separates them (what program the reader ran; whether this host is the packaged desktop binary, which the plugin measures and reports rather than assumes), carries the one conversion a model can make itself (rewrite the work as PowerShell or `cmd` when an MSYS2 program is what could not start), and \u2014 for the Electron host \u2014 names the fix #8193 measured rather than a wider mode: host the runner on a real node.exe (the desktop ships one under `resources/runtime/primary-runtime/dependencies/node/bin/node.exe`), where the same confined `pwsh`/`cmd` spawns succeed, while `danger-full-access` is described as a way to confirm the diagnosis and not a fix, together with the warning that unsetting `ELECTRON_RUN_AS_NODE` instead would leave the desktop's Electron-hosted runner unable to execute `runner.js` at all (the interaction #8193 records with #8174), and says plainly which producers it does not know \u2014 it never claims the sandbox caused the failure and never offers a widened mode as a fix. An optional, off-by-default `enforceAfter` refuses an identical ACL call this plugin has watched fail, bounded by `maxDenials`; the blocking half is ACL-only by design. It never edits an ACL, never elevates, never sets another process's environment, and never changes a preset or a mode, and it complements repeat-guard-escalation, which keys on call identity rather than on the environment signature.",
4
- "version": "0.7.1",
3
+ "description": "Turns three sandbox environment failures that name neither their cause nor a remedy into a diagnosis with a path forward. Family 1, the Windows workspace ACL: ten reports (#7538, #7622, #7646, #7720, #7750, #7735, #7771, #7804, #7816, #8232) of one signature \u2014 every sandboxed command fails before it runs with `SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace>)`, because the merged DACL + mandatory-label write needs WRITE_OWNER on the directory (an object right the caller can self-grant), not SeSecurityPrivilege and not elevation; the host grant is materialized lazily and caches nothing on the failure path, so the same failure repeats per command. Family 2, the persistent shell (#7638): with the `minimal` preset under a confining sandbox mode every shell call dies instantly with `PTY shell exited during startup` because the terminal backend cannot create the pseudo-console inside the sandbox, retrying never helps, and `minimal` mounts no fallback shell tool. Family 3, a confined Windows child that died during native initialization (#7876, #7877): every command spawned through the sandbox runner can report exit 0xC0000142 STATUS_DLL_INIT_FAILED with the process never reaching its entry point \u2014 the packaged desktop starts that runner as [process.execPath, entry], and that host has been measured twice with one indistinguishable appearance from inside a session \u2014 either nothing on the runner path ran because the Electron binary launches as an application unless the child's environment carries ELECTRON_RUN_AS_NODE=1 (#7876), or the desktop launcher does set that variable and the child still dies because the restricted token is derived from the Electron process image (#8193), and an MSYS2 / Git-Bash program cannot create its signal pipe under the restricted token while cmd.exe and pwsh run fine in the same workspace under the same mode (#7877) \u2014 and because upstream's runner-failure rules admit only exit 127 with the `windows-acl-run: ` signature, the code is never an error: it arrives as the canonical value of a result the pipeline calls a success. The plugin observes the public `tools/post-execute` waterfall, classifies all three signatures narrowly (only the two `...NamedSecurityInfoW` operations; the PTY message matched on a whole line, never as a substring; the loader status read as a 32-bit integer out of the shell tool's own canonical success value, never from rendered text; both mode-gated families advised only under a mode the policy resolver reports as confining), and attaches ONE durable user-role advisory per agent per family through `additionalContexts`. The ACL advisory names the missing right, both environments the identical text can describe (an inherited Modify-only entry, a data volume where no ACE names the caller at all, and a directory owned by another account), the version boundary that arrived with the mandatory label (0.1.7-alpha.1, flag 20, versus the DACL-only flag 4 up to 0.1.6-alpha.x) together with why downgrading is not the remedy, the discriminator, the remedy forked on an ownership check the user runs (`(Get-Acl \"<dir>\").Owner`), because one command cannot serve both rights situations: where the caller owns the directory, the unelevated `icacls ... :(OI)(CI)(WO)` is the whole of what is missing \u2014 the owner's implicit WRITE_DAC already covers the DACL half \u2014 and where the caller does not own it that same command is refused for want of WRITE_DAC, so the grant has to come from an elevated account, or by taking ownership first, or by moving the workspace under %USERPROFILE% \u2014 and the two remedies that look right and are not (`takeown`, `icacls /reset`), each with the reason it fails — and it states the two things about the failure's shape that the reports had to measure for themselves (#8232): that it belongs to the workspace rather than to the command (a command that only reads fails identically, so there is no harmless retry), and that the grant is scoped to the directory it names and its children, so a sibling workspace root on the same volume needs the line once more; the PTY advisory names the failing combination, states the resolved mode, tells the model to stop rather than retry, and hands the user-side preset choice over \u2014 it never names a shell tool the failing composition does not mount; the native-init advisory states the resolved mode, says the process died before its entry point, enumerates the two producers measured under a confining mode \u2014 naming both measured shapes of the desktop host rather than asserting the one that was measured first \u2014 with the check that separates them (what program the reader ran; whether this host is the packaged desktop binary, which the plugin measures and reports rather than assumes), carries the one conversion a model can make itself (rewrite the work as PowerShell or `cmd` when an MSYS2 program is what could not start), and \u2014 for the Electron host \u2014 names the fix #8193 measured rather than a wider mode: host the runner on a real node.exe (the desktop ships one under `resources/runtime/primary-runtime/dependencies/node/bin/node.exe`), where the same confined `pwsh`/`cmd` spawns succeed, while `danger-full-access` is described as a way to confirm the diagnosis and not a fix, together with the warning that unsetting `ELECTRON_RUN_AS_NODE` instead would leave the desktop's Electron-hosted runner unable to execute `runner.js` at all (the interaction #8193 records with #8174), and says plainly which producers it does not know \u2014 it never claims the sandbox caused the failure and never offers a widened mode as a fix. An optional, off-by-default `enforceAfter` refuses an identical ACL call this plugin has watched fail, bounded by `maxDenials`; the blocking half is ACL-only by design. It never edits an ACL, never elevates, never sets another process's environment, and never changes a preset or a mode, and it complements repeat-guard-escalation, which keys on call identity rather than on the environment signature.",
4
+ "version": "0.8.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",