@argszero/cordis-plugin-sandbox-grant-advisor 0.4.0 → 0.6.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/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.
@@ -20,6 +21,24 @@
20
21
  * separates "this is the label failure" from "this is something else", and
21
22
  * because rolling back is the reach it invites while making the very problem
22
23
  * it closed come back.
24
+ *
25
+ * **The remedy is forked on ownership, because one command cannot serve both
26
+ * environments.** The reports split into two rights situations behind an
27
+ * identical error: a workspace **the caller owns** (`#7622`, `#7646`, `#7720`,
28
+ * `#7750`, `#7804`), where the owner's implicit `WRITE_DAC` satisfies the DACL
29
+ * half and `(WO)` is the whole of what is missing — so the `icacls /grant`
30
+ * that supplies it *can itself run*, unelevated; and a directory **the caller
31
+ * does not own** (`#7771`: owner `BUILTIN\Administrators`, held deny-only for
32
+ * their token), where `WRITE_DAC` is missing too, so `icacls /grant` is denied
33
+ * for the very command that would fix it, and `(WO)` alone would not be enough
34
+ * even if it went through. The classifier cannot tell these apart — the text is
35
+ * identical — so the advisory does what it can do instead of guessing: it hands
36
+ * over the **ownership check** (`(Get-Acl "<dir>").Owner`) as the branch
37
+ * selector, then gives each branch the command that actually works there, and
38
+ * says why the other branch's command is not a fallback. A single unconditional
39
+ * one-liner would send the second environment to a command that is refused
40
+ * before it runs — the same defect this module exists to answer, a remedy that
41
+ * does not work delivered confidently.
23
42
  * - **The persistent-shell failure** (`pty-startup`) is *not* fixable by the
24
43
  * caller — least of all by the model, which has no shell to run anything in.
25
44
  * So its advice says so and stops: the remedy is a user-side preset choice,
@@ -27,6 +46,19 @@
27
46
  * Handing the model a command here would be advice to run something that
28
47
  * cannot run, and naming a one-shot shell tool would be advice to call a tool
29
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).
30
62
  *
31
63
  * Both give a **discriminator, not just a remedy**: applying a fix without
32
64
  * confirming the cause teaches nothing when the fix does not work. For the ACL
@@ -34,15 +66,21 @@
34
66
  * SID and grants `(F)` — which separates "Modify-only directory" from "the
35
67
  * documented prerequisite is wrong", the open question upstream. For the PTY
36
68
  * family it is the **effective sandbox mode**, which is why that advisory is
37
- * only ever built with the mode the call actually ran under.
69
+ * only ever built with the mode the call actually ran under. For the native-init
70
+ * family it is two checks the reader performs — which program could not start,
71
+ * and whether this host is the packaged desktop app — because the code alone
72
+ * cannot separate the producers and a guess would send half its readers to the
73
+ * wrong remedy.
38
74
  *
39
75
  * @module
40
76
  */
41
- import { failureLine } from './signature.js';
77
+ import { failureLine, STATUS_DLL_INIT_FAILED } from './signature.js';
42
78
  /** The upstream threads the ACL advisory is a stopgap for. */
43
- export const ACL_DISCUSSIONS = '#7538 / #7622 / #7646 / #7720 / #7750 / #7735';
79
+ export const ACL_DISCUSSIONS = '#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816';
44
80
  /** The upstream thread the persistent-shell advisory is a stopgap for. */
45
81
  export const PTY_DISCUSSIONS = '#7638';
82
+ /** The upstream threads the native-init-death advisory is a stopgap for. */
83
+ export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877';
46
84
  /** The documented prerequisite, quoted from the backend's README. */
47
85
  export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
48
86
  /**
@@ -81,6 +119,19 @@ export const NOT_FIXES = [
81
119
  ];
82
120
  /** Placeholder the user replaces with the directory the error named. */
83
121
  const PLACEHOLDER = '<the directory from the error line above>';
122
+ /**
123
+ * Whether this process is running on an Electron binary.
124
+ *
125
+ * In the packaged desktop the harness host *is* Electron, started with
126
+ * `ELECTRON_RUN_AS_NODE=1` so it behaves as Node — which is why the variable is
127
+ * defined here and why its presence is the discriminator the `#7876` producer
128
+ * turns on: `sandbox-local` launches the sandbox runner as `process.execPath`,
129
+ * and in that build the exec path is the Electron executable.
130
+ * @returns true when `process.versions.electron` is set.
131
+ */
132
+ function electronHost() {
133
+ return process.versions.electron !== undefined;
134
+ }
84
135
  /**
85
136
  * The diagnosis paragraph for one class of failure.
86
137
  * @param failure - the recognized failure.
@@ -111,7 +162,7 @@ function diagnosis(failure) {
111
162
  return [
112
163
  'Why it is refused: this is the same merged write, but the Win32 code is not ERROR_ACCESS_DENIED (5), so',
113
164
  'the missing-rights story above does not apply verbatim — a missing path, a non-directory target, or a',
114
- 'filesystem that does not carry ACLs are all possibilities. The one-line fix below is safe to try; if the',
165
+ 'filesystem that does not carry ACLs are all possibilities. The commands below are safe to try; if the',
115
166
  'code persists, it is a different failure and worth reporting with the code.',
116
167
  ].join('\n');
117
168
  }
@@ -129,7 +180,11 @@ function diagnosis(failure) {
129
180
  * diagnosis.
130
181
  *
131
182
  * A negative claim still has to be earned: the failure to avoid is advice that
132
- * is confidently wrong in the other direction.
183
+ * is confidently wrong in the other direction. That is why the `takeown` line
184
+ * claims only what is true in **both** ownership branches: it supplies the DACL
185
+ * half and never `WRITE_OWNER`, so it is not the fix by itself — while still
186
+ * being a legitimate first step (with elevation) where the caller is not the
187
+ * owner. Calling it useless outright would have been the mirror-image error.
133
188
  * @param failure - the recognized provisioning failure.
134
189
  * @param path - the directory the error named, or the placeholder.
135
190
  * @returns the section's lines, or an empty array for a class it does not fit.
@@ -138,10 +193,11 @@ function nonFixes(failure, path) {
138
193
  if (failure.klass !== 'apply-denied')
139
194
  return [];
140
195
  return [
141
- 'What will NOT fix it — both look like the right move, and both were tried and reported:',
196
+ 'What will NOT fix it on its own — both look like the right move, and both were tried and reported:',
142
197
  ` takeown /F "${path}" /R /D Y`,
143
- " makes you the owner, but ownership's implicit rights are READ_CONTROL and WRITE_DAC only.",
144
- ' The owner does not implicitly hold WRITE_OWNER, which is the right this call needs.',
198
+ " makes you the owner, and ownership's implicit rights are READ_CONTROL and WRITE_DAC only — so it",
199
+ ' supplies the DACL half and still not WRITE_OWNER, the right this call needs. In the second branch above',
200
+ ' it is a legitimate first step with elevation; it is never the fix by itself.',
145
201
  ` icacls "${path}" /reset /T /C`,
146
202
  ' restores inheritance, and inheritance is what supplied the Modify-only ACE above.',
147
203
  '',
@@ -174,11 +230,11 @@ function versionBoundary() {
174
230
  *
175
231
  * The family decides everything: one function so a caller does not have to
176
232
  * remember which family needs which fact, and so the mode requirement of the
177
- * PTY family is enforced by construction rather than by convention.
233
+ * two gated families is enforced by construction rather than by convention.
178
234
  * @param failure - the recognized failure.
179
235
  * @param context - what the caller knows about the failing call.
180
236
  * @returns the user-role notice text, with any remedy ready to paste.
181
- * @throws when a PTY failure is advised without its resolved sandbox mode.
237
+ * @throws when a mode-gated failure is advised without its resolved sandbox mode.
182
238
  */
183
239
  export function advisoryText(failure, context = {}) {
184
240
  if (failure.family === 'pty-startup') {
@@ -187,6 +243,12 @@ export function advisoryText(failure, context = {}) {
187
243
  }
188
244
  return ptyAdvisory(failure, context.mode, context.tool, context.href);
189
245
  }
246
+ if (failure.family === 'native-init') {
247
+ if (context.mode === undefined) {
248
+ throw new Error('sandbox-grant-advisor: the native-init advisory requires the resolved sandbox mode');
249
+ }
250
+ return nativeInitAdvisory(failure, context.mode, context.electronHost ?? electronHost(), context.href);
251
+ }
190
252
  return aclAdvisory(failure, context.href);
191
253
  }
192
254
  /**
@@ -214,17 +276,36 @@ function aclAdvisory(failure, href) {
214
276
  '(WO) / Write owner. If the strongest entry naming you is (M) / Modify — or no entry names you at all and',
215
277
  'your access comes from an inherited `Authenticated Users:(M)` — that is this failure.',
216
278
  '',
217
- 'Fix it (unelevated, one line) and then run the command again:',
279
+ 'Ownership decides which of the two commands below can work, so read it first — PowerShell 5.1 or later:',
280
+ ` (Get-Acl "${path}").Owner # compare with: whoami`,
281
+ 'If that is not your own account, take the second branch: the first one is refused before it runs.',
282
+ '',
283
+ 'IF YOU OWN THE DIRECTORY — the usual workspace, whether on the system drive or a data volume:',
284
+ ' one unelevated line, then run the command again:',
218
285
  ` PowerShell: icacls "${path}" /grant "$env:USERNAME:(OI)(CI)(WO)"`,
219
286
  ` cmd: icacls "${path}" /grant "%USERNAME%:(OI)(CI)(WO)"`,
220
287
  'WRITE_OWNER is exactly the right the prerequisite names, so this grants nothing the harness did not ask for,',
221
288
  'and (OI)(CI) makes the ACE inheritable, so one command reaches the workspace\'s existing subdirectories.',
222
289
  'Full control works just as well — the same line with `F` in place of `(WO)`:',
223
290
  ` icacls "${path}" /grant "$env:USERNAME:(OI)(CI)F"`,
224
- 'Both assume you own the directory: owner-implicit rights cover the DACL half of the merged write, so',
225
- 'WRITE_OWNER is the single missing piece. A directory owned by someone else is a bigger change than a',
226
- 'one-liner — that is the harness\'s documented prerequisite, and it is why this failure is loud instead of',
227
- 'silently skipped.',
291
+ 'Why `(WO)` is the whole of what is missing there: an owner holds READ_CONTROL and WRITE_DAC implicitly,',
292
+ 'and WRITE_DAC is what `icacls /grant` itself needs — so the DACL half of the merged write already has what',
293
+ 'it wants, and WRITE_OWNER is the single missing piece.',
294
+ '',
295
+ 'IF YOU DO NOT OWN IT — a directory an installer or another account created, e.g. owner',
296
+ '`BUILTIN\\Administrators`:',
297
+ ' the line above cannot run at all. Changing a DACL takes WRITE_DAC, which you hold neither as owner nor',
298
+ ' through any ACE, so `icacls /grant` is refused with `Access is denied` — for the very command that would',
299
+ ' fix it. The merged write wants WRITE_DAC and WRITE_OWNER together, so `(WO)` alone would not be enough',
300
+ ' here even if it went through. Run the grant once from an account that already holds both — that is, from an',
301
+ ' ELEVATED prompt:',
302
+ ` icacls "${path}" /grant "<your-account>:(OI)(CI)F"`,
303
+ 'Full control is used because it is the rights set covering both halves; the other reach both reports name is',
304
+ 'to take ownership first, which also needs elevation (it wants SeTakeOwnership), after which the unelevated',
305
+ '`(WO)` line above applies:',
306
+ ` icacls "${path}" /setowner "<your-account>"`,
307
+ 'Or sidestep the ACL entirely: create the workspace under `%USERPROFILE%` — a directory created there',
308
+ 'inherits Full control for you — and open the session on that one.',
228
309
  '',
229
310
  ...nonFixes(failure, path),
230
311
  'How to read this: the harness documents the prerequisite (' + PREREQUISITE + ') and this',
@@ -233,6 +314,78 @@ function aclAdvisory(failure, href) {
233
314
  'Your file read/write tools still work; only sandboxed command execution is blocked.',
234
315
  ].join('\n');
235
316
  }
317
+ /**
318
+ * Build the advisory for a confined Windows child that never reached its entry
319
+ * point.
320
+ *
321
+ * Three things this text must not do: retry silently (the identical call cannot
322
+ * start), hand the model a command to run (there may be no working shell to run
323
+ * it in — that is what died), or assert which producer this is. The code is a
324
+ * loader status and says nothing about the sandbox by itself, so the diagnosis
325
+ * is the *class* ("the process never started") plus the two producers that have
326
+ * been measured under this harness, each with the check that distinguishes it.
327
+ * One of those checks the plugin answers itself and reports as a fact — whether
328
+ * this process is an Electron binary — rather than assuming, because a reader
329
+ * told "this is the packaged desktop" without evidence would be reading a guess
330
+ * dressed as a finding.
331
+ * @param failure - the recognized failure.
332
+ * @param mode - the resolved sandbox mode the failing call ran under.
333
+ * @param onElectron - whether this process is an Electron binary.
334
+ * @param href - optional URL shown for the upstream threads.
335
+ * @returns the user-role notice text.
336
+ */
337
+ function nativeInitAdvisory(failure, mode, onElectron, href) {
338
+ const where = href === undefined ? `tracked upstream (discussions ${NATIVE_INIT_DISCUSSIONS})` : `tracked upstream: ${href}`;
339
+ return [
340
+ 'Sandboxed command never started — the process died while its native libraries were loading.',
341
+ '',
342
+ 'What was reported:',
343
+ ` ${failureLine(failure)} (0xC0000142 STATUS_DLL_INIT_FAILED)`,
344
+ `The call ran under sandbox mode \`${mode}\`, where the harness starts every command through its`,
345
+ 'restricted-token runner.',
346
+ '',
347
+ `0x${failure.exitCode.toString(16).toUpperCase()} is STATUS_DLL_INIT_FAILED: the Windows loader terminated the process while it was`,
348
+ 'initializing its DLLs and C runtime, which is BEFORE the program\'s entry point. A command that ran and',
349
+ 'then failed exits with its own status and prints its own output; this one produced neither. The number',
350
+ 'is also not a portable exit status — those are 0-255, and this is a 32-bit NTSTATUS. Nothing in the code',
351
+ 'says "sandbox" by itself; what makes the sandbox a candidate is the mode above, under which every',
352
+ 'command is spawned through the ACL runner.',
353
+ '',
354
+ 'Two producers have been measured under a confining Windows mode. Check which one this is:',
355
+ ' 1. An MSYS2 / Git-Bash program — `bash.exe`, `sh.exe`, or anything from a Git for Windows or MSYS2',
356
+ ' distribution. Under the restricted token its runtime cannot create the pipe it uses for signals,',
357
+ ' and it aborts in the loader phase (`couldn\'t create signal pipe, Win32 error 5`), while `cmd.exe`',
358
+ ' and PowerShell run fine in the same workspace under the same mode (#7877).',
359
+ ' If that is what could not start: write the same work as a PowerShell or `cmd` command instead and',
360
+ ' continue — do not retry the MSYS2 program.',
361
+ ' 2. The packaged desktop application\'s sandbox runner. `dsh-sandbox-local` starts the runner as',
362
+ ' `[process.execPath, runner.js]`, and in the packaged build `process.execPath` is the Electron',
363
+ ' executable, which starts as an *application* unless the child\'s environment carries',
364
+ ' `ELECTRON_RUN_AS_NODE=1` — so the runner never runs and every confined command reports this code',
365
+ ' with no output at all (#7876).',
366
+ onElectron
367
+ ? ' This process IS an Electron binary (`process.versions.electron` is set), so that producer applies here:'
368
+ : ' This process is NOT an Electron binary (`process.versions.electron` is unset), so the runner is a real',
369
+ onElectron
370
+ ? ' run the same command with `danger-full-access`, or from an unpacked `node apps/cli/lib/bin.js web`'
371
+ : ' Node binary here and that producer cannot be the cause. If the program was not an MSYS2 one either,',
372
+ onElectron
373
+ ? ' host where the runner is a real Node binary. If it works there, the runner never ran and this is the cause.'
374
+ : ' this failure is outside both measured producers: stop and hand it to the user.',
375
+ '',
376
+ 'Do not retry this call: the environment has not changed, and the identical call produces the identical',
377
+ 'code. Convert the work only in case 1; otherwise stop and hand it to the user.',
378
+ '',
379
+ 'Honest boundary — 0xC0000142 has producers this list does not have: a program that cannot load one of',
380
+ 'its own DLLs dies this way too, and the sandbox backend\'s own source records that a child started with',
381
+ 'a hidden console window does as well (which is why that backend avoids `CREATE_NO_WINDOW`). This is not',
382
+ 'a claim that the sandbox caused the failure — the code cannot say that. What is claimed is narrower and',
383
+ 'checkable: the process never reached its entry point, and under this mode these two producers are known.',
384
+ '',
385
+ 'This is a stopgap, ' + where + '. What it is NOT: this plugin neither changes an environment nor',
386
+ 'widens the sandbox — the checks above are yours to make, and `danger-full-access` is not offered as a fix.',
387
+ ].join('\n');
388
+ }
236
389
  /**
237
390
  * Build the advisory for a persistent-shell startup failure.
238
391
  *
@@ -286,14 +439,18 @@ function ptyAdvisory(failure, mode, tool, href) {
286
439
  * Build the pre-dispatch denial for the optional fail-fast half.
287
440
  *
288
441
  * The blocking half is deliberately **ACL-only**, and this function's parameter
289
- * type is where that is enforced. The PTY family gets an advisory and nothing
290
- * else, for a reason that is about the remedy rather than about the failure:
442
+ * type is where that is enforced. The two mode-gated families get an advisory
443
+ * and nothing else, for a reason that is about the remedy rather than about the
444
+ * failure:
291
445
  * the ACL remedy is a command the user can run *while the session continues*,
292
446
  * so refusing further identical calls cannot make the session unfinishable —
293
447
  * spending the budget always lets the call through, and a repaired environment
294
448
  * is discovered by exactly that. The PTY remedy is a preset swap, which happens
295
- * between sessions; refusing calls could only pad a session that is already
296
- * unable to do the thing being refused.
449
+ * between sessions, and the native-init remedy is a launch fix on the user's
450
+ * side (the one in-session part — rewriting an MSYS2 command — the model does by
451
+ * calling a different tool invocation, which has a different call key and is
452
+ * therefore never the call being refused); refusing calls could only pad a
453
+ * session that is already unable to do the thing being refused.
297
454
  * @param failure - the recognized failure.
298
455
  * @param observed - how many provisioning failures this agent has produced.
299
456
  * @param denial - this denial's 1-based ordinal.
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * `sandbox-grant-advisor`: turn an environment failure that has no path forward
3
3
  * into a diagnosis the model — and the user reading the transcript — can act on.
4
4
  *
5
- * ## The two failures it recognizes
5
+ * ## The three failures it recognizes
6
6
  *
7
7
  * **Workspace provisioning (Windows ACL).** Four reports of one signature
8
8
  * (`#7538`, `#7622`, `#7646`, `#7720`) describe the same shape: the host-side write grant
@@ -40,13 +40,42 @@
40
40
  * `danger-full-access` succeeds, `standard` (one-shot shell) × confining
41
41
  * succeeds.
42
42
  *
43
+ * **A confined child that never started (native init, `#7876` + `#7877`).** The
44
+ * third family is not a message at all: two reports of one exit code —
45
+ * `0xC0000142` `STATUS_DLL_INIT_FAILED` — describing a child that died while the
46
+ * loader was initializing its native images, before its entry point. In `#7876`
47
+ * the packaged desktop's sandbox runner never runs, because `sandbox-local`
48
+ * starts it as `[process.execPath, entry]` and in that build `process.execPath`
49
+ * is the Electron executable, which starts as an *application* unless the child
50
+ * carries `ELECTRON_RUN_AS_NODE=1` — so every confined command reports this code
51
+ * with no output at all, while the unpacked `node apps/cli/lib/bin.js web` host
52
+ * is unaffected. In `#7877` it is an MSYS2/Git-Bash program: under the restricted
53
+ * token bash cannot create its own signal pipe (`couldn't create signal pipe,
54
+ * Win32 error 5`), so it dies in the same phase, while `cmd.exe` and `pwsh` run
55
+ * fine under the identical mode. Retrying is the one thing that cannot work, and
56
+ * the code tells the model nothing on its own.
57
+ *
58
+ * This family is read from the **canonical value of a successful result**, which
59
+ * is why the seam below now inspects both outcomes. The producer never marks it
60
+ * an error: upstream's runner-failure rules admit only exit `127` with the
61
+ * `windows-acl-run: ` signature (`packages/sandbox/sandbox-local/src/index.ts`),
62
+ * `classifyRunnerFailure` skips every other code before it looks at stderr
63
+ * (`packages/sandbox/sandbox/src/diagnostics.ts`), and the renderer reports a
64
+ * nonzero exit as `[exit code: N]` rather than as `isError`
65
+ * (`packages/shell/tool-pwsh/src/render.ts`). Every version of this plugin
66
+ * before `0.6.0` read error results only and was structurally blind to it. See
67
+ * `src/signature.ts` for why the read is `ToolExecutionSuccess.value` — the
68
+ * tool's own canonical output, never a line of rendered text — and which three
69
+ * narrowings keep the recognition from firing on something else.
70
+ *
43
71
  * ## Where it acts, and why there
44
72
  *
45
73
  * One listener on the public `tools/post-execute` waterfall
46
74
  * (`@deepseek-ai/dsh-tools`). Admissibility was decided by which half of the
47
75
  * defect this seam can reach: the failure text (the provider propagates its
48
- * error unchanged, and the tool pipeline turns it into an `isError` result), an
49
- * agent identity to attribute it to (`exec.agent`), and a channel that speaks
76
+ * error unchanged, and the tool pipeline turns it into an `isError` result) or,
77
+ * for the native-init family, the canonical value a successful result carries,
78
+ * an agent identity to attribute it to (`exec.agent`), and a channel that speaks
50
79
  * to the model in the same step (`PostToolDecision`'s `additionalContexts`,
51
80
  * a durable user-role message).
52
81
  *
@@ -71,7 +100,14 @@
71
100
  * right and are not (`takeown`, `icacls /reset`), each with its reason. For the PTY family it names the
72
101
  * combination that fails (persistent PTY × a confining mode), states the
73
102
  * resolved mode, says plainly that no command can fix it, and hands the
74
- * user-side preset choice over. Both ride `additionalContexts`, so the model
103
+ * user-side preset choice over. For the native-init family it states the
104
+ * resolved mode, says the process never reached its entry point, enumerates
105
+ * the two producers measured under a confining mode with the check that
106
+ * separates them (what program the reader ran; whether this host is the
107
+ * packaged desktop binary, which the plugin **measures and reports** rather
108
+ * than assumes), and carries the one conversion a model can actually make —
109
+ * rewrite the work as PowerShell or `cmd` when the program that could not
110
+ * start was an MSYS2 one. All three ride `additionalContexts`, so the model
75
111
  * sees the diagnosis beside the failure rather than only in a log it never
76
112
  * reads.
77
113
  * 2. **A bounded fail-fast, ACL family only.** With `enforceAfter` set, a call
@@ -80,9 +116,10 @@
80
116
  * default: the useful signal here is the diagnosis, and a plugin that blocks
81
117
  * command execution for a reason it merely recognizes is a risk, not a
82
118
  * feature. See the README for why the blocking half is deliberately narrow
83
- * and why it does not cover the PTY family.
84
- * 3. **A disclosure when it withholds.** The PTY advisory is only sent when the
85
- * resolved mode actually confines; if the mode is not confining, or cannot be
119
+ * and why it does not cover the two mode-gated families.
120
+ * 3. **A disclosure when it withholds.** The PTY and native-init advisories are
121
+ * only sent when the resolved mode actually confines; if the mode is not
122
+ * confining, or cannot be
86
123
  * resolved at all, the failure is left exactly as it was **and the host log
87
124
  * says so once**. Silence alone would make "the sandbox is not the cause" and
88
125
  * "this plugin could not tell" indistinguishable from the outside.
@@ -96,26 +133,36 @@
96
133
  * `ToolRuntime` — against synthetic results carrying the producers' exact
97
134
  * error shapes, with the formats taken from
98
135
  * `packages/subprocess/win32-process/src/errors.ts` and
99
- * `packages/terminal/terminal-bash/src/{index,session}.ts`.
136
+ * `packages/terminal/terminal-bash/src/{index,session}.ts`. The native-init
137
+ * family is exercised the same way and needs no Windows to be faithful, because
138
+ * what it reads is a number in a JSON value: the test builds the shipped shell
139
+ * tools' own foreground projection with the reported codes, including the
140
+ * signed form the reporter saw (`-1073741502`) and the real MSYS2 stderr, so the
141
+ * recognition runs against the producer's data rather than against a message
142
+ * this plugin invented.
100
143
  * - **It does not repair anything.** No ACL is written, no privilege is
101
- * requested, nothing is elevated, no preset is installed and no mode is
102
- * changed: both remedies are the user's to apply.
144
+ * requested, nothing is elevated, no environment variable is set for another
145
+ * process, no preset is installed and no mode is changed: the remedies are the
146
+ * user's (or, for the one in-session conversion, the model's own rewrite).
103
147
  * - **It complements, rather than replaces, `repeat-guard-escalation`.** That
104
148
  * guard keys on *call identity* (identical arguments retried); this one keys
105
149
  * on the *environment signature*, which is how several different commands can
106
150
  * share one cause. They can be mounted together.
107
- * - **The real fix is upstream**, in both families: the ACL failure should name
151
+ * - **The real fix is upstream**, in all three families: the ACL failure should
152
+ * name
108
153
  * the outstanding condition at the site that knows it (`grantWrite` computes
109
154
  * `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
110
- * false), and the PTY startup path should either report "this sandbox mode is
111
- * incompatible with the PTY backend" or fall back to a one-shot shell. This
112
- * plugin is the stopgap.
155
+ * false), the PTY startup path should either report "this sandbox mode is
156
+ * incompatible with the PTY backend" or fall back to a one-shot shell, and the
157
+ * sandbox runner should be launched with the environment its own execution
158
+ * needs (`ELECTRON_RUN_AS_NODE` when argv[0] is an Electron binary) or with a
159
+ * documented, checkable refusal for MSYS2 programs. This plugin is the stopgap.
113
160
  *
114
161
  * @module @argszero/cordis-plugin-sandbox-grant-advisor
115
162
  */
116
163
  import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
117
- import { advisoryText, ACL_DISCUSSIONS, denialText, PTY_DISCUSSIONS } from './advice.js';
118
- import { classifyProvisioningFailure, classifyPtyStartupFailure } from './signature.js';
164
+ import { advisoryText, ACL_DISCUSSIONS, denialText, NATIVE_INIT_DISCUSSIONS, PTY_DISCUSSIONS } from './advice.js';
165
+ import { classifyNativeInitDeath, classifyProvisioningFailure, classifyPtyStartupFailure } from './signature.js';
119
166
  import { confines, resolveSandboxMode } from './mode.js';
120
167
  import { advisedOf, callKey, observe, observeSuccess, recordAdvice, recordDenial, recordWithheld, shouldDeny, } from './state.js';
121
168
  export const name = 'sandbox-grant-advisor';
@@ -139,8 +186,9 @@ export const DEFAULT_MAX_DENIALS = 2;
139
186
  * The family the optional blocking half applies to.
140
187
  *
141
188
  * The ACL remedy is a command the user can run while the session continues; the
142
- * PTY remedy is a preset swap between sessions. Refusing calls is only useful
143
- * in the first case — see `denialText` in `src/advice.ts`.
189
+ * PTY remedy is a preset swap between sessions and the native-init remedy is a
190
+ * user-side launch fix (or a rewrite the model makes itself), so refusing calls
191
+ * is only useful in the first case — see `denialText` in `src/advice.ts`.
144
192
  */
145
193
  export const ENFORCED_FAMILY = 'acl-provisioning';
146
194
  /** Compile one `*`-wildcard pattern to an anchored RegExp; all else is literal. */
@@ -200,6 +248,22 @@ function ptyHostLine(mode) {
200
248
  + `"${mode}" — a PTY backend cannot start under a confining mode, and retrying cannot help; advisory `
201
249
  + `delivered to the model (discussion ${PTY_DISCUSSIONS})`;
202
250
  }
251
+ /**
252
+ * The one-line host-side account of a recognized native-init death.
253
+ *
254
+ * It names the two things a maintainer needs to place the report — the code and
255
+ * the mode the call ran under — and deliberately not a cause: the code alone
256
+ * cannot say which producer it was, and a log line that guesses is the same
257
+ * defect as an advisory that guesses.
258
+ * @param failure - the recognized failure.
259
+ * @param mode - the resolved sandbox mode the failing call ran under.
260
+ * @returns a single log line.
261
+ */
262
+ function nativeInitHostLine(failure, mode) {
263
+ return `sandbox-grant-advisor: sandboxed command reported exit ${String(failure.rawExitCode)} `
264
+ + `(0x${failure.exitCode.toString(16).toUpperCase()} STATUS_DLL_INIT_FAILED) under sandbox mode "${mode}" — the child died before `
265
+ + `its entry point, so retrying cannot help; advisory delivered to the model (discussions ${NATIVE_INIT_DISCUSSIONS})`;
266
+ }
203
267
  /**
204
268
  * Wrap one notice as a user-role message.
205
269
  *
@@ -232,9 +296,14 @@ function prepend(ours, theirs) {
232
296
  }
233
297
  /** The one-line transcript summary for a recognized failure. */
234
298
  function summaryOf(failure, mode) {
235
- return failure.family === 'pty-startup'
236
- ? `persistent shell exited during startup under sandbox mode "${String(mode)}"`
237
- : `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`;
299
+ if (failure.family === 'pty-startup') {
300
+ return `persistent shell exited during startup under sandbox mode "${String(mode)}"`;
301
+ }
302
+ if (failure.family === 'native-init') {
303
+ return `sandboxed command never started (exit ${String(failure.rawExitCode)}, STATUS_DLL_INIT_FAILED) `
304
+ + `under sandbox mode "${String(mode)}"`;
305
+ }
306
+ return `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`;
238
307
  }
239
308
  /**
240
309
  * Install the advisor.
@@ -270,18 +339,24 @@ export function apply(ctx, config = {}) {
270
339
  * Withholding is a decision, not an absence: the transcript shows a bare error
271
340
  * either way, so the difference between "this is not the sandbox's doing" and
272
341
  * "this plugin could not tell" has to be recorded where a maintainer reads it.
273
- * Once per agent, because a loop can produce dozens of these.
342
+ * Once per agent, because a loop can produce dozens of these. The cited thread
343
+ * is the *family's* — a withheld native-init death and a withheld PTY startup
344
+ * failure are different reports, and pointing a maintainer at the wrong one
345
+ * would be its own small misdiagnosis.
274
346
  * @param agent - the agent whose failure was withheld.
275
347
  * @param state - the agent's state, to keep the note to one.
276
348
  * @param why - what stopped the advisory.
349
+ * @param failure - the recognized failure that was withheld; only the two
350
+ * mode-gated families reach this function, so the thread it cites is exact.
277
351
  * @returns undefined, so callers can `return withhold(...)`.
278
352
  */
279
- function withhold(agent, state, why) {
353
+ function withhold(agent, state, why, failure) {
280
354
  if (state?.withheld === true)
281
355
  return undefined;
282
356
  states.set(agent, recordWithheld(state));
283
- ctx.logger.warn(`sandbox-grant-advisor: persistent-shell startup failure recognized but no advisory sent — ${why}; the raw `
284
- + `error is left exactly as it is, so this is NOT a claim that the sandbox is unrelated (discussion ${PTY_DISCUSSIONS})`);
357
+ const discussions = failure.family === 'pty-startup' ? PTY_DISCUSSIONS : NATIVE_INIT_DISCUSSIONS;
358
+ ctx.logger.warn(`sandbox-grant-advisor: ${failure.family} failure recognized but no advisory sent — ${why}; the raw `
359
+ + `error is left exactly as it is, so this is NOT a claim that the sandbox is unrelated (discussions ${discussions})`);
285
360
  return undefined;
286
361
  }
287
362
  /**
@@ -302,11 +377,20 @@ export function apply(ctx, config = {}) {
302
377
  const key = callKey(exec.name, exec.arguments);
303
378
  const previous = states.get(agent);
304
379
  if (result.isError !== true) {
305
- if (previous !== undefined)
306
- states.set(agent, observeSuccess(previous, key));
307
- return undefined;
380
+ // A result the pipeline calls a success is not automatically a working
381
+ // environment: the native-init death arrives exactly here, as the
382
+ // canonical value of a command that "finished" with a loader status. It is
383
+ // read from `result.value` and never from the rendered text, so a command
384
+ // whose own output mentions the code cannot be mistaken for it.
385
+ const death = classifyNativeInitDeath(result.value);
386
+ if (death === undefined) {
387
+ if (previous !== undefined)
388
+ states.set(agent, observeSuccess(previous, key));
389
+ return undefined;
390
+ }
391
+ return adviseGated(agent, previous, death, key, exec.name);
308
392
  }
309
- // The two families read different fields, on purpose. The ACL signature
393
+ // The two text families read different fields, on purpose. The ACL signature
310
394
  // carries an API name plus a Win32 code, which a command's own output does
311
395
  // not fabricate, so that family may read the merged text (`error.message`
312
396
  // with the rendered content as its fallback). The persistent-shell
@@ -328,28 +412,47 @@ export function apply(ctx, config = {}) {
328
412
  ?? classifyPtyStartupFailure(result.error.message);
329
413
  if (failure === undefined)
330
414
  return undefined;
331
- // The PTY diagnosis IS the sandbox mode, so it is resolved before anything
332
- // is recorded: an unconfined mode is not this family's story, and a mode
333
- // that cannot be resolved is not something to guess at. Either way the
334
- // failure is left untouched and the host log accounts for the silence.
335
- if (failure.family === 'pty-startup') {
336
- const resolution = resolveSandboxMode(ctx, agent);
337
- if (!resolution.ok)
338
- return withhold(agent, previous, resolution.withheld);
339
- const mode = resolution.mode;
340
- if (!confines(mode)) {
341
- return withhold(agent, previous, `the failing call ran under \`${mode}\`, where the shell is not spawned through the sandbox`);
342
- }
343
- if (!claimAdvice(agent, previous, failure, key))
344
- return undefined;
345
- ctx.logger.warn(ptyHostLine(mode));
346
- return notice(advisoryText(failure, advisoryContext(exec.name, mode)), summaryOf(failure, mode));
347
- }
415
+ if (failure.family === 'pty-startup')
416
+ return adviseGated(agent, previous, failure, key, exec.name);
348
417
  if (!claimAdvice(agent, previous, failure, key))
349
418
  return undefined;
350
419
  ctx.logger.warn(aclHostLine(failure));
351
420
  return notice(advisoryText(failure, advisoryContext(exec.name)), summaryOf(failure));
352
421
  }
422
+ /**
423
+ * Diagnose one recognized failure of a **mode-gated** family.
424
+ *
425
+ * Both families the plugin gates on the sandbox mode — the persistent shell
426
+ * and the native-init death — need the same three decisions before anything is
427
+ * said, and they need them in the same order, so they share one implementation
428
+ * rather than one each: the effective mode is resolved from the agent's own
429
+ * session, a mode that does not confine withholds the advisory (the harness
430
+ * does not spawn commands through the sandbox there, so this is not these
431
+ * families' story), and a mode that cannot be resolved withholds it too rather
432
+ * than falling back to a guess. In both withholding cases the failure is left
433
+ * untouched and the host log accounts for the silence once.
434
+ * @param agent - the agent whose call failed.
435
+ * @param previous - the agent's state before this call, if any.
436
+ * @param failure - the recognized failure, already known to be a gated family.
437
+ * @param key - the identity of the failing call.
438
+ * @param tool - the failing tool's name, for the advisory context.
439
+ * @returns the notice to attach, or undefined.
440
+ */
441
+ function adviseGated(agent, previous, failure, key, tool) {
442
+ const resolution = resolveSandboxMode(ctx, agent);
443
+ if (!resolution.ok)
444
+ return withhold(agent, previous, resolution.withheld, failure);
445
+ const mode = resolution.mode;
446
+ if (!confines(mode)) {
447
+ const what = failure.family === 'pty-startup' ? 'the shell' : 'the command';
448
+ return withhold(agent, previous, `the failing call ran under \`${mode}\`, where ${what} is not spawned `
449
+ + 'through the sandbox', failure);
450
+ }
451
+ if (!claimAdvice(agent, previous, failure, key))
452
+ return undefined;
453
+ ctx.logger.warn(failure.family === 'pty-startup' ? ptyHostLine(mode) : nativeInitHostLine(failure, mode));
454
+ return notice(advisoryText(failure, advisoryContext(tool, mode)), summaryOf(failure, mode));
455
+ }
353
456
  /**
354
457
  * Record one recognized failure and claim the once-per-agent advisory for its
355
458
  * family.