@argszero/cordis-plugin-sandbox-grant-advisor 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -22
- package/lib/advice.js +139 -10
- package/lib/index.js +149 -46
- package/lib/signature.js +117 -1
- package/lib/state.js +5 -4
- package/lib/types/advice.d.ts +50 -11
- package/lib/types/index.d.ts +67 -18
- package/lib/types/signature.d.ts +119 -3
- package/lib/types/state.d.ts +5 -4
- package/package.json +2 -2
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
|
|
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),
|
|
49
|
-
*
|
|
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.
|
|
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
|
|
84
|
-
* 3. **A disclosure when it withholds.** The PTY
|
|
85
|
-
* resolved mode actually confines; if the mode is not
|
|
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
|
|
102
|
-
* changed:
|
|
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
|
|
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),
|
|
111
|
-
* incompatible with the PTY backend" or fall back to a one-shot shell
|
|
112
|
-
*
|
|
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
|
|
143
|
-
*
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
|
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
|
|
12
|
-
* that cannot be provisioned (`acl-provisioning`)
|
|
13
|
-
* cannot start (`pty-startup`)
|
|
14
|
-
*
|
|
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
|
package/lib/types/advice.d.ts
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
|
|
7
|
-
*
|
|
6
|
+
* by a test, one family at a time. The three families are shaped by the same
|
|
7
|
+
* question — is this the sandbox's doing, and what can the reader do about it —
|
|
8
|
+
* and they answer it differently:
|
|
8
9
|
*
|
|
9
10
|
* - **The ACL failure** (`acl-provisioning`) *is* fixable by the caller, so its
|
|
10
11
|
* advice names the right the caller is missing and gives the command.
|
|
@@ -45,6 +46,24 @@
|
|
|
45
46
|
* Handing the model a command here would be advice to run something that
|
|
46
47
|
* cannot run, and naming a one-shot shell tool would be advice to call a tool
|
|
47
48
|
* the failing composition does not mount.
|
|
49
|
+
* - **The native-init death** (`native-init`) is the one whose remedy is **split**:
|
|
50
|
+
* the *class* is not the model's to fix, but one of its two measured producers
|
|
51
|
+
* is. A confined child that died with `STATUS_DLL_INIT_FAILED` never ran
|
|
52
|
+
* anything, so retrying the same call is pure waste — but if the program that
|
|
53
|
+
* could not start was an MSYS2/Git-Bash one, the same work expressed with
|
|
54
|
+
* PowerShell or `cmd` runs fine under the identical mode, and the model *can*
|
|
55
|
+
* make that change because the model is the one that wrote the command. So the
|
|
56
|
+
* advice carries a stop instruction, the one in-session conversion, and the
|
|
57
|
+
* user-side remedy for the other producer. What it deliberately does **not** do
|
|
58
|
+
* is guess which producer this is: the code alone cannot say, and the two
|
|
59
|
+
* checks it hands over are facts the reader holds (what program they ran;
|
|
60
|
+
* whether this is the packaged desktop app, which the plugin reports rather
|
|
61
|
+
* than assumes). That Electron host is itself **two measurements**, not one —
|
|
62
|
+
* a runner that never started at all, and a runner that did start and whose
|
|
63
|
+
* child cannot survive the restricted token derived from an Electron process
|
|
64
|
+
* image — and the advisory names both instead of asserting the one it shipped
|
|
65
|
+
* first, because a session cannot tell them apart and a confidently wrong
|
|
66
|
+
* cause is worse than two named ones with one shared remedy.
|
|
48
67
|
*
|
|
49
68
|
* Both give a **discriminator, not just a remedy**: applying a fix without
|
|
50
69
|
* confirming the cause teaches nothing when the fix does not work. For the ACL
|
|
@@ -52,7 +71,13 @@
|
|
|
52
71
|
* SID and grants `(F)` — which separates "Modify-only directory" from "the
|
|
53
72
|
* documented prerequisite is wrong", the open question upstream. For the PTY
|
|
54
73
|
* family it is the **effective sandbox mode**, which is why that advisory is
|
|
55
|
-
* only ever built with the mode the call actually ran under.
|
|
74
|
+
* only ever built with the mode the call actually ran under. For the native-init
|
|
75
|
+
* family it is two checks the reader performs — which program could not start,
|
|
76
|
+
* and whether this host is the packaged desktop app — because the code alone
|
|
77
|
+
* cannot separate the producers and a guess would send half its readers to the
|
|
78
|
+
* wrong remedy. Inside that Electron answer there is a third thing the code
|
|
79
|
+
* cannot separate, and the advisory deliberately does not try: it names both
|
|
80
|
+
* measurements and says the remedy does not depend on choosing between them.
|
|
56
81
|
*
|
|
57
82
|
* @module
|
|
58
83
|
*/
|
|
@@ -62,6 +87,8 @@ import type { SandboxModeName } from './mode.js';
|
|
|
62
87
|
export declare const ACL_DISCUSSIONS = "#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816";
|
|
63
88
|
/** The upstream thread the persistent-shell advisory is a stopgap for. */
|
|
64
89
|
export declare const PTY_DISCUSSIONS = "#7638";
|
|
90
|
+
/** The upstream threads the native-init-death advisory is a stopgap for. */
|
|
91
|
+
export declare const NATIVE_INIT_DISCUSSIONS = "#7876 / #7877 / #8193";
|
|
65
92
|
/** The documented prerequisite, quoted from the backend's README. */
|
|
66
93
|
export declare const PREREQUISITE = "granted directories must be caller-owned and grant `WRITE_OWNER`";
|
|
67
94
|
/**
|
|
@@ -103,35 +130,47 @@ export interface AdvisoryContext {
|
|
|
103
130
|
readonly tool?: string;
|
|
104
131
|
/**
|
|
105
132
|
* The sandbox mode the failing call ran under. Required by the
|
|
106
|
-
* `pty-startup` family — the whole diagnosis is the mode — and
|
|
107
|
-
*
|
|
133
|
+
* `pty-startup` family — the whole diagnosis is the mode — and by the
|
|
134
|
+
* `native-init` family, whose gate is the same question; unused by the ACL
|
|
135
|
+
* family.
|
|
108
136
|
*/
|
|
109
137
|
readonly mode?: SandboxModeName;
|
|
138
|
+
/**
|
|
139
|
+
* Whether this process is an Electron binary (`process.versions.electron`).
|
|
140
|
+
* Used by the `native-init` family to report — not to assume — which of its
|
|
141
|
+
* producers this host can have; absent means "ask the live process", so a
|
|
142
|
+
* caller cannot accidentally state a fact it did not measure.
|
|
143
|
+
*/
|
|
144
|
+
readonly electronHost?: boolean;
|
|
110
145
|
}
|
|
111
146
|
/**
|
|
112
147
|
* Build the advisory attached to the failing tool result.
|
|
113
148
|
*
|
|
114
149
|
* The family decides everything: one function so a caller does not have to
|
|
115
150
|
* remember which family needs which fact, and so the mode requirement of the
|
|
116
|
-
*
|
|
151
|
+
* two gated families is enforced by construction rather than by convention.
|
|
117
152
|
* @param failure - the recognized failure.
|
|
118
153
|
* @param context - what the caller knows about the failing call.
|
|
119
154
|
* @returns the user-role notice text, with any remedy ready to paste.
|
|
120
|
-
* @throws when a
|
|
155
|
+
* @throws when a mode-gated failure is advised without its resolved sandbox mode.
|
|
121
156
|
*/
|
|
122
157
|
export declare function advisoryText(failure: RecognizedFailure, context?: AdvisoryContext): string;
|
|
123
158
|
/**
|
|
124
159
|
* Build the pre-dispatch denial for the optional fail-fast half.
|
|
125
160
|
*
|
|
126
161
|
* The blocking half is deliberately **ACL-only**, and this function's parameter
|
|
127
|
-
* type is where that is enforced. The
|
|
128
|
-
* else, for a reason that is about the remedy rather than about the
|
|
162
|
+
* type is where that is enforced. The two mode-gated families get an advisory
|
|
163
|
+
* and nothing else, for a reason that is about the remedy rather than about the
|
|
164
|
+
* failure:
|
|
129
165
|
* the ACL remedy is a command the user can run *while the session continues*,
|
|
130
166
|
* so refusing further identical calls cannot make the session unfinishable —
|
|
131
167
|
* spending the budget always lets the call through, and a repaired environment
|
|
132
168
|
* is discovered by exactly that. The PTY remedy is a preset swap, which happens
|
|
133
|
-
* between sessions
|
|
134
|
-
*
|
|
169
|
+
* between sessions, and the native-init remedy is a launch fix on the user's
|
|
170
|
+
* side (the one in-session part — rewriting an MSYS2 command — the model does by
|
|
171
|
+
* calling a different tool invocation, which has a different call key and is
|
|
172
|
+
* therefore never the call being refused); refusing calls could only pad a
|
|
173
|
+
* session that is already unable to do the thing being refused.
|
|
135
174
|
* @param failure - the recognized failure.
|
|
136
175
|
* @param observed - how many provisioning failures this agent has produced.
|
|
137
176
|
* @param denial - this denial's 1-based ordinal.
|