@argszero/cordis-plugin-sandbox-grant-advisor 0.5.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/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.
package/lib/signature.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Recognize the two environment failures this plugin explains, and refuse
2
+ * Recognize the three environment failures this plugin explains, and refuse
3
3
  * everything else.
4
4
  *
5
5
  * ## The ACL provisioning failure (`acl-provisioning`)
@@ -82,10 +82,87 @@
82
82
  * different cause space (a slow or blocked shell) with a different remedy, and
83
83
  * a classifier that names a wrong cause is worse than one that stays silent.
84
84
  *
85
+ * ## The process that never started (`native-init`)
86
+ *
87
+ * The third family is not a message at all: it is a **structured exit code on a
88
+ * result the pipeline calls a success**. Two reports of one code —
89
+ * `STATUS_DLL_INIT_FAILED`, `0xC0000142`, seen as `-1073741502` in a tool result
90
+ * because Windows exit codes are 32-bit NTSTATUS values and Node reports them
91
+ * signed — describe a confined child that died while its native images were
92
+ * initializing, i.e. before its entry point. `#7876` is the packaged desktop app:
93
+ * `sandbox-local` starts the sandbox runner as `[process.execPath, entry]`, and
94
+ * in that build `process.execPath` is the Electron executable, which starts as an
95
+ * *app* unless `ELECTRON_RUN_AS_NODE=1` is in the child's environment — so the
96
+ * runner itself never runs and every confined command reports this code with no
97
+ * output at all. `#7877` is an MSYS2/Git-Bash program under the restricted
98
+ * token: bash cannot create its own signal pipe (`couldn't create signal pipe,
99
+ * Win32 error 5`) and aborts in the same place, while `cmd.exe` and `pwsh` run
100
+ * fine under the identical mode.
101
+ *
102
+ * **This family is the only one that is invisible from the error path**, and that
103
+ * is the whole reason it is classified from the canonical value instead of from
104
+ * text. Upstream's runner-failure rules admit exactly one code —
105
+ * `RUNNER_FAILURE_RULES['windows-acl'] = [{ allowedExitCodes: [127], fatalSignatures:
106
+ * ['windows-acl-run: '] }]` (`packages/sandbox/sandbox-local/src/index.ts`) — and
107
+ * `classifyRunnerFailure` skips any other code before it even looks at stderr
108
+ * (`packages/sandbox/sandbox/src/diagnostics.ts`), so `0xC0000142` is never a
109
+ * runner failure and `SandboxUnavailableError` is never thrown. The renderer then
110
+ * reports it the way it reports any finished command — *"Non-zero exits are
111
+ * reported, not errored … only infrastructure failures (spawn errors, aborts)
112
+ * surface as isError results"* (`packages/shell/tool-pwsh/src/render.ts`) — as
113
+ * `[exit code: …]`. A plugin reading only `isError` results (every version of
114
+ * this one before `0.6.0`) is structurally blind to it, which is exactly why the
115
+ * model retries a command that can never start.
116
+ *
117
+ * The read is `ToolExecutionSuccess.value` — the tool's own canonical output,
118
+ * documented as *"Execution-local canonical value; deliberately omitted from
119
+ * durable events"* (`packages/core/tools/src/index.ts`) — so the code arrives
120
+ * structurally and no line of rendered text can be mistaken for it. Reading it
121
+ * this way is what makes the recognition safe: a command that prints a line
122
+ * saying `0xC0000142` is not this failure, and a call whose arguments merely
123
+ * mention a Windows path is not either.
124
+ *
125
+ * Three narrowings, each of which is a thing that could otherwise make the
126
+ * diagnosis wrong:
127
+ *
128
+ * - **The `foreground` discriminator is required.** The shipped shell tools
129
+ * project a finished foreground run as `{ kind: 'foreground', exitCode, … }`
130
+ * and a still-running background handle as a different shape (`tool-pwsh` /
131
+ * `tool-bash`, mirrored by design), so requiring it keeps a value some other
132
+ * tool happens to build with an `exitCode` field out of this family. A value
133
+ * without it is left alone — the fail-closed direction, since the cost of
134
+ * silence is one missing diagnosis and the cost of a wrong match is a confident
135
+ * wrong cause.
136
+ * - **Only `STATUS_DLL_INIT_FAILED` is classified.** `0xC0000142` has producers
137
+ * this module does not know about (a program that simply cannot load its own
138
+ * DLLs, and the console-hiding that the sandbox backend's own source records as
139
+ * producing it), so the advisory enumerates the measured ones and says so
140
+ * rather than asserting one. The neighbouring statuses are deliberately **not**
141
+ * folded in: `0xC0000409` is the Cygwin/MSYS2 runtime's deliberate fast-fail
142
+ * (a different mechanism with a different story), and `0xC0000135` is a missing
143
+ * DLL (a packaging problem, not a sandbox one).
144
+ * - **No platform gate.** The code is a Windows NTSTATUS: a POSIX process cannot
145
+ * exit with a value above 255, so the number itself is the platform evidence. A
146
+ * `process.platform === 'win32'` check would add nothing a session could
147
+ * observe and would make this family untestable on the host this plugin is
148
+ * built on — which is how a family ships without ever having been run.
149
+ *
85
150
  * @module
86
151
  */
87
152
  /** The exact text `dsh-terminal-bash` throws when the shell exits during startup. */
88
153
  export const PTY_STARTUP_EXIT = 'PTY shell exited during startup';
154
+ /**
155
+ * `STATUS_DLL_INIT_FAILED`, the code a Windows process is terminated with when
156
+ * the loader fails while initializing it — before its entry point runs.
157
+ *
158
+ * Written unsigned here, which is how the NTSTATUS is named; a tool result
159
+ * usually carries it as the 32-bit signed number (`-1073741502`), and
160
+ * {@link classifyNativeInitDeath} accepts either because it compares the
161
+ * normalized 32-bit pattern.
162
+ */
163
+ export const STATUS_DLL_INIT_FAILED = 0xC0000142;
164
+ /** The `kind` discriminator the shipped shell tools put on a finished foreground run. */
165
+ const FOREGROUND = 'foreground';
89
166
  /**
90
167
  * The producer's format is fixed by `Win32Error`
91
168
  * (`packages/subprocess/win32-process/src/errors.ts`):
@@ -149,6 +226,43 @@ export function classifyPtyStartupFailure(message) {
149
226
  }
150
227
  return undefined;
151
228
  }
229
+ /**
230
+ * Classify one **successful** execution's canonical value as a Windows native-init
231
+ * death.
232
+ *
233
+ * This is the only family read from a result the pipeline calls a success, and
234
+ * that is a fact about the producer rather than a choice: the code reaches the
235
+ * tool result as an ordinary nonzero exit status (upstream's runner-failure rules
236
+ * admit only exit `127` with the `windows-acl-run: ` signature, so this one is
237
+ * never reclassified), and the renderer reports nonzero exits without erroring.
238
+ * `ToolExecutionFailure` carries no value at all, so there is nothing to read on
239
+ * the error path — a session sees this failure exactly when its shell tool
240
+ * reports a command that "ran".
241
+ *
242
+ * Recognized structurally, never from text: the value must be the foreground
243
+ * shell projection (`kind: 'foreground'`) with an integer `exitCode` whose 32-bit
244
+ * pattern is {@link STATUS_DLL_INIT_FAILED}. A command's own output claiming the
245
+ * code cannot reach this function, and neither can a value some other tool built
246
+ * with an `exitCode` field.
247
+ * @param value - the settled execution's canonical value (`ToolExecutionSuccess.value`).
248
+ * @returns the recognized failure, or undefined when this is not one.
249
+ */
250
+ export function classifyNativeInitDeath(value) {
251
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
252
+ return undefined;
253
+ const probe = value;
254
+ if (probe.kind !== FOREGROUND)
255
+ return undefined;
256
+ const raw = probe.exitCode;
257
+ if (typeof raw !== 'number' || !Number.isInteger(raw))
258
+ return undefined;
259
+ // `>>> 0` maps the signed form Node reports on Windows onto the unsigned
260
+ // NTSTATUS, and leaves a value that is already unsigned alone.
261
+ const exitCode = raw >>> 0;
262
+ if (exitCode !== STATUS_DLL_INIT_FAILED)
263
+ return undefined;
264
+ return { family: 'native-init', rawExitCode: raw, exitCode };
265
+ }
152
266
  /**
153
267
  * The one-line failure the producer wrote, for quoting back verbatim.
154
268
  * @param failure - a recognized failure.
@@ -157,6 +271,8 @@ export function classifyPtyStartupFailure(message) {
157
271
  export function failureLine(failure) {
158
272
  if (failure.family === 'pty-startup')
159
273
  return failure.line;
274
+ if (failure.family === 'native-init')
275
+ return `[exit code: ${String(failure.rawExitCode)}]`;
160
276
  const suffix = failure.detail.length === 0 ? '' : `: ${failure.detail}`;
161
277
  return `${failure.api} failed (Win32 ${failure.win32Code})${suffix}`;
162
278
  }
package/lib/state.js CHANGED
@@ -8,10 +8,11 @@
8
8
  *
9
9
  * ## One record per family
10
10
  *
11
- * This plugin now recognizes two unrelated environment failures — a workspace
12
- * that cannot be provisioned (`acl-provisioning`) and a persistent shell that
13
- * cannot start (`pty-startup`). They are different diagnoses with different
14
- * remedies, so their bookkeeping is kept apart under one agent
11
+ * This plugin recognizes three unrelated environment failures — a workspace
12
+ * that cannot be provisioned (`acl-provisioning`), a persistent shell that
13
+ * cannot start (`pty-startup`), and a confined Windows child that died during
14
+ * native initialization (`native-init`). They are different diagnoses with
15
+ * different remedies, so their bookkeeping is kept apart under one agent
15
16
  * ({@link AgentState.families}): an agent that hits both is told about both,
16
17
  * and an agent that has already been told about one is still told about the
17
18
  * other. Sharing one "already advised" flag would silently swallow the second
@@ -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,19 @@
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).
48
62
  *
49
63
  * Both give a **discriminator, not just a remedy**: applying a fix without
50
64
  * confirming the cause teaches nothing when the fix does not work. For the ACL
@@ -52,7 +66,11 @@
52
66
  * SID and grants `(F)` — which separates "Modify-only directory" from "the
53
67
  * documented prerequisite is wrong", the open question upstream. For the PTY
54
68
  * 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.
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.
56
74
  *
57
75
  * @module
58
76
  */
@@ -62,6 +80,8 @@ import type { SandboxModeName } from './mode.js';
62
80
  export declare const ACL_DISCUSSIONS = "#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816";
63
81
  /** The upstream thread the persistent-shell advisory is a stopgap for. */
64
82
  export declare const PTY_DISCUSSIONS = "#7638";
83
+ /** The upstream threads the native-init-death advisory is a stopgap for. */
84
+ export declare const NATIVE_INIT_DISCUSSIONS = "#7876 / #7877";
65
85
  /** The documented prerequisite, quoted from the backend's README. */
66
86
  export declare const PREREQUISITE = "granted directories must be caller-owned and grant `WRITE_OWNER`";
67
87
  /**
@@ -103,35 +123,47 @@ export interface AdvisoryContext {
103
123
  readonly tool?: string;
104
124
  /**
105
125
  * The sandbox mode the failing call ran under. Required by the
106
- * `pty-startup` family — the whole diagnosis is the mode — and unused by the
107
- * ACL family.
126
+ * `pty-startup` family — the whole diagnosis is the mode — and by the
127
+ * `native-init` family, whose gate is the same question; unused by the ACL
128
+ * family.
108
129
  */
109
130
  readonly mode?: SandboxModeName;
131
+ /**
132
+ * Whether this process is an Electron binary (`process.versions.electron`).
133
+ * Used by the `native-init` family to report — not to assume — which of its
134
+ * producers this host can have; absent means "ask the live process", so a
135
+ * caller cannot accidentally state a fact it did not measure.
136
+ */
137
+ readonly electronHost?: boolean;
110
138
  }
111
139
  /**
112
140
  * Build the advisory attached to the failing tool result.
113
141
  *
114
142
  * The family decides everything: one function so a caller does not have to
115
143
  * remember which family needs which fact, and so the mode requirement of the
116
- * PTY family is enforced by construction rather than by convention.
144
+ * two gated families is enforced by construction rather than by convention.
117
145
  * @param failure - the recognized failure.
118
146
  * @param context - what the caller knows about the failing call.
119
147
  * @returns the user-role notice text, with any remedy ready to paste.
120
- * @throws when a PTY failure is advised without its resolved sandbox mode.
148
+ * @throws when a mode-gated failure is advised without its resolved sandbox mode.
121
149
  */
122
150
  export declare function advisoryText(failure: RecognizedFailure, context?: AdvisoryContext): string;
123
151
  /**
124
152
  * Build the pre-dispatch denial for the optional fail-fast half.
125
153
  *
126
154
  * The blocking half is deliberately **ACL-only**, and this function's parameter
127
- * type is where that is enforced. The PTY family gets an advisory and nothing
128
- * else, for a reason that is about the remedy rather than about the failure:
155
+ * type is where that is enforced. The two mode-gated families get an advisory
156
+ * and nothing else, for a reason that is about the remedy rather than about the
157
+ * failure:
129
158
  * the ACL remedy is a command the user can run *while the session continues*,
130
159
  * so refusing further identical calls cannot make the session unfinishable —
131
160
  * spending the budget always lets the call through, and a repaired environment
132
161
  * is discovered by exactly that. The PTY remedy is a preset swap, which happens
133
- * between sessions; refusing calls could only pad a session that is already
134
- * unable to do the thing being refused.
162
+ * between sessions, and the native-init remedy is a launch fix on the user's
163
+ * side (the one in-session part — rewriting an MSYS2 command — the model does by
164
+ * calling a different tool invocation, which has a different call key and is
165
+ * therefore never the call being refused); refusing calls could only pad a
166
+ * session that is already unable to do the thing being refused.
135
167
  * @param failure - the recognized failure.
136
168
  * @param observed - how many provisioning failures this agent has produced.
137
169
  * @param denial - this denial's 1-based ordinal.