@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/README.md CHANGED
@@ -1,27 +1,32 @@
1
1
  # @argszero/cordis-plugin-sandbox-grant-advisor
2
2
 
3
3
  Turns a sandbox environment failure that has **no path forward** into a
4
- diagnosis the model — and the user reading the transcript — can act on. Two
4
+ diagnosis the model — and the user reading the transcript — can act on. Three
5
5
  signatures, one mechanism:
6
6
 
7
7
  ```
8
8
  SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws) # Windows workspace ACL
9
9
  PTY shell exited during startup # persistent shell × confining mode
10
+ [exit code: -1073741502] (0xC0000142) # a confined child that never started
10
11
  ```
11
12
 
12
13
  **This plugin is the stopgap for "the error does not name the outstanding
13
14
  condition".** It repairs nothing: no ACL is written, no privilege is requested,
14
- nothing is elevated, no preset is installed and no mode is changed.
15
+ nothing is elevated, no environment variable is set for another process, no
16
+ preset is installed and no mode is changed.
15
17
 
16
- ## The two failures it recognizes
18
+ ## The three failures it recognizes
17
19
 
18
- Both are recognized on the public **`tools/post-execute`** waterfall
19
- (`@deepseek-ai/dsh-tools`) — the one seam that has all three of: the failure text
20
+ The first two are recognized on the public **`tools/post-execute`** waterfall
21
+ (`@deepseek-ai/dsh-tools`) from the failure text. That seam is the one that has
22
+ all three of what a diagnosis needs: the failure
20
23
  (providers propagate their error unchanged and the tool pipeline settles it as an
21
24
  `isError` result), an agent identity to attribute it to (`exec.agent`), and a
22
25
  channel that speaks to the model in the same step (`PostToolDecision`'s
23
26
  `additionalContexts`, which the agent loop turns into a durable user-role message
24
- — `packages/core/agent-loop/src/tool-calls.ts`).
27
+ — `packages/core/agent-loop/src/tool-calls.ts`). The third is recognized at the
28
+ **same seam** from the canonical value of a result the pipeline calls a
29
+ *success*, for a reason §3 gives in full.
25
30
 
26
31
  That seam, not `ctx.sandbox.confine`: `confine(argv, policy, signal)` sees the
27
32
  confinement failure too, but its signature carries no agent, so a wrapper could
@@ -113,18 +118,56 @@ confines deletes to the workspace, and reverting it reintroduces the escape it c
113
118
  Confirm the cause (unelevated) — `icacls` is a normal user command:
114
119
  icacls "D:\ws"
115
120
 
116
- Fix it (unelevated, one line) and then run the command again:
121
+ Ownership decides which of the two commands below can work, so read it first — PowerShell 5.1 or later:
122
+ (Get-Acl "D:\ws").Owner # compare with: whoami
123
+
124
+ IF YOU OWN THE DIRECTORY — the usual workspace, on a data volume as much as on C::
125
+ one unelevated line, then run the command again:
117
126
  PowerShell: icacls "D:\ws" /grant "$env:USERNAME:(OI)(CI)(WO)"
118
127
  cmd: icacls "D:\ws" /grant "%USERNAME%:(OI)(CI)(WO)"
119
-
120
- What will NOT fix it — both look like the right move, and both were tried and reported:
128
+ ... Full control works just as well — the same line with `F` in place of `(WO)`
129
+
130
+ IF YOU DO NOT OWN IT — a directory an installer or another account created, e.g. owner
131
+ `BUILTIN\Administrators`:
132
+ the line above cannot run at all. Changing a DACL takes WRITE_DAC, which you hold neither as owner nor
133
+ through any ACE, so `icacls /grant` is refused with `Access is denied` — for the very command that would
134
+ fix it. ... Run the grant once from an account that already holds both — that is, from an ELEVATED prompt:
135
+ icacls "D:\ws" /grant "<your-account>:(OI)(CI)F"
136
+ ... or take ownership first (also elevated; it wants SeTakeOwnership), after which the unelevated `(WO)`
137
+ line above applies: icacls "D:\ws" /setowner "<your-account>"
138
+ ... or sidestep the ACL: create the workspace under `%USERPROFILE%`.
139
+
140
+ What will NOT fix it on its own — both look like the right move, and both were tried and reported:
121
141
  takeown /F "D:\ws" /R /D Y
122
- makes you the owner, but ownership's implicit rights are READ_CONTROL and WRITE_DAC only.
123
- The owner does not implicitly hold WRITE_OWNER, which is the right this call needs.
142
+ makes you the owner, and ownership's implicit rights are READ_CONTROL and WRITE_DAC only — so it
143
+ supplies the DACL half and still not WRITE_OWNER, the right this call needs.
124
144
  icacls "D:\ws" /reset /T /C
125
145
  restores inheritance, and inheritance is what supplied the Modify-only ACE above.
126
146
  ```
127
147
 
148
+ **Why the remedy forks** (added in 0.5.0). The same error covers two different
149
+ rights situations, and one command cannot serve both. Where the caller **owns**
150
+ the directory, the owner's implicit `WRITE_DAC` satisfies the DACL half of the
151
+ merged write, `WRITE_OWNER` is the single missing right, and the unelevated
152
+ `icacls /grant` that supplies it can itself run — [#7750] measured exactly that
153
+ fix working. Where the caller **does not own** it ([#7771]: owner
154
+ `BUILTIN\Administrators`, held deny-only for that token), `WRITE_DAC` is missing
155
+ too, so the very same command is refused before it does anything, and `(WO)`
156
+ alone would not be enough even if it went through. The failure text is identical
157
+ in both, so the classifier cannot pick a branch — the advisory hands over the
158
+ **ownership check** as the selector instead of guessing, which is also the
159
+ actionable-guidance half of what [#7771] asked for. Until 0.5.0 a single
160
+ unconditional one-liner was printed, with a sentence noting it assumed
161
+ ownership; that would have sent the second environment to a command that is
162
+ denied — the same defect this plugin exists to answer.
163
+
164
+ **What is deliberately *not* shipped**: `icacls ... /grant "<user>:(OI)(CI)(WD,WO)"`,
165
+ the two needed rights named explicitly. It is the tighter form and it is
166
+ plausibly correct syntax, but this project has no Windows host to run it on, and
167
+ shipping an unverified command in a remedy whose whole point is that it works is
168
+ the failure mode being fixed. `F` (verified by [#7804]'s reporter) and
169
+ `/setowner` (named by both reports) are given instead.
170
+
128
171
  ### 2. Persistent shell startup (`pty-startup`)
129
172
 
130
173
  [Discussion #7638] reports the second shape: with the **`minimal` preset** on
@@ -191,15 +234,111 @@ reads it any more (`@deepseek-ai/dsh-agent-preset-registry`: the registry
191
234
  by row id (`preset-minimal`) for a change to a shipped one. A test arm asserts
192
235
  the advisory never names the dead directory.
193
236
 
237
+ ### 3. A confined child that never started (`native-init`)
238
+
239
+ Two reports of one exit code: [`#7876`] (the packaged desktop app) and [`#7877`]
240
+ (MSYS2 / Git Bash). Both are `0xC0000142` `STATUS_DLL_INIT_FAILED` — the Windows
241
+ loader terminated the process while it was initializing its native images, i.e.
242
+ **before the program's entry point**. A command that ran and then failed exits
243
+ with its own status and prints its own output; this one produced neither.
244
+
245
+ | what was run | what came out | exit code |
246
+ | --- | --- | --- |
247
+ | `cmd.exe /c "echo cmd-ok"` | `cmd-ok` | 0 |
248
+ | `pwsh -NoLogo -NoProfile -Command "Write-Output pwsh-ok"` | `pwsh-ok` | 0 |
249
+ | `D:\Git\bin\bash.exe -c "echo bash-ok"` | `couldn't create signal pipe, Win32 error 5` | `-1073741502` |
250
+
251
+ Two producers have been measured under a confining mode:
252
+
253
+ 1. **An MSYS2 / Git-Bash program** ([`#7877`]). The restricted token's runtime
254
+ cannot create the pipe it uses for signals, so bash aborts in the loader
255
+ phase, while `cmd.exe` and `pwsh` run fine in the same workspace under the
256
+ same mode. The plugin's own composition has no way around this: `tool-bash`
257
+ and `bash-sandbox` are `disabled` on win32
258
+ (`@deepseek-ai/dsh-base/cordis.patch.yml`), so the combination is likely
259
+ never covered upstream. The **one conversion a model can make itself** is to
260
+ write the same work as a PowerShell or `cmd` command.
261
+ 2. **The packaged desktop app's sandbox runner** ([`#7876`]).
262
+ `dsh-sandbox-local` launches the runner as `[process.execPath, entry]`, and
263
+ in the packaged build `process.execPath` is the Electron executable, which
264
+ starts as an *application* unless the child's environment carries
265
+ `ELECTRON_RUN_AS_NODE=1` — so the runner never runs and every confined command
266
+ reports this code with **no output at all**. The unpacked node host
267
+ (`node apps/cli/lib/bin.js web`) is unaffected. The plugin reports whether
268
+ *this* process is an Electron binary (`process.versions.electron`) as a
269
+ measured fact rather than assuming it, because that is the check the
270
+ discriminator turns on.
271
+
272
+ **Why this family is read from a successful result.** The producer never marks
273
+ it an error, and that is a fact about upstream rather than a choice here:
274
+ `RUNNER_FAILURE_RULES['windows-acl']` admits exactly one code —
275
+ `[{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]`
276
+ (`packages/sandbox/sandbox-local/src/index.ts`) — and `classifyRunnerFailure`
277
+ skips any other code before it looks at stderr
278
+ (`packages/sandbox/sandbox/src/diagnostics.ts`), so `0xC0000142` is never a
279
+ runner failure and `SandboxUnavailableError` is never thrown. The renderer then
280
+ reports it the way it reports any finished command — *"Non-zero exits are
281
+ reported, not errored … only infrastructure failures (spawn errors, aborts)
282
+ surface as isError results"* (`packages/shell/tool-pwsh/src/render.ts`) — as
283
+ `[exit code: …]`. **Every version of this plugin before 0.6.0 read error results
284
+ only and was structurally blind to it**, which is exactly why the model retries a
285
+ command that can never start.
286
+
287
+ The read is `ToolExecutionSuccess.value` — the tool's own canonical output,
288
+ documented as *"Execution-local canonical value; deliberately omitted from
289
+ durable events"* **and not carried on failure results at all** — so the code
290
+ arrives structurally rather than as a line of text. A command that prints
291
+ `[exit code: -1073741502]` is not this failure, and neither is a value some other
292
+ tool happens to build with an `exitCode` field: the classifier requires the
293
+ shipped shell projection (`kind: 'foreground'` plus an integer `exitCode`).
294
+
295
+ The advisory that follows:
296
+
297
+ ```
298
+ Sandboxed command never started — the process died while its native libraries were loading.
299
+
300
+ What was reported:
301
+ [exit code: -1073741502] (0xC0000142 STATUS_DLL_INIT_FAILED)
302
+ The call ran under sandbox mode `workspace-write`, where the harness starts every command through its
303
+ restricted-token runner.
304
+
305
+ 0xC0000142 is STATUS_DLL_INIT_FAILED: ... this is BEFORE the program's entry point. ...
306
+ Nothing in the code says "sandbox" by itself; what makes the sandbox a candidate is the mode above ...
307
+
308
+ Two producers have been measured under a confining Windows mode. Check which one this is:
309
+ 1. An MSYS2 / Git-Bash program ... (#7877)
310
+ If that is what could not start: write the same work as a PowerShell or `cmd` command instead
311
+ 2. The packaged desktop application's sandbox runner ... (#7876)
312
+ This process is NOT an Electron binary (`process.versions.electron` is unset), so that producer does not apply here.
313
+
314
+ Do not retry this call: the environment has not changed, and the identical call produces the identical code.
315
+
316
+ Honest boundary — 0xC0000142 has producers this list does not have: a program that cannot load one of
317
+ its own DLLs dies this way too, and the sandbox backend's own source records that a child started with
318
+ a hidden console window does as well ... This is not a claim that the sandbox caused the failure.
319
+ ```
320
+
321
+ **What it does not claim.** The code is a loader status, and the loader reports
322
+ the same status for causes that have nothing to do with the sandbox (a missing
323
+ DLL, a program's own initialization failure, the hidden-console child the
324
+ backend's own source avoids `CREATE_NO_WINDOW` for). So the advisory diagnoses
325
+ the **class** ("the process never reached its entry point") and enumerates the
326
+ producers measured under a confining mode, each with the check that separates
327
+ them — one of which the reader answers (what program failed to start) and one of
328
+ which the plugin answers (is this host the packaged desktop binary). It never
329
+ offers `danger-full-access` as a fix and never suggests a sandbox setting be
330
+ relaxed.
331
+
194
332
  ## What it does with a recognized failure
195
333
 
196
- 1. **One durable advisory per agent, per family.** An agent that hits both
334
+ 1. **One durable advisory per agent, per family.** An agent that hits two
197
335
  families is told about **both**, once each. The notice carries its own
198
336
  producer-owned `source.kind` (`sandbox-grant-advisor`) — not the retired
199
337
  `plugin` wrapper, which the current session format refuses — and a bounded
200
338
  one-line `summary` for the transcript row. The host log gets one matching
201
339
  `warn` line, so the fact survives outside the transcript too.
202
- 2. **A disclosure when it withholds.** The PTY advisory is only sent when the
340
+ 2. **A disclosure when it withholds.** The PTY and native-init advisories are
341
+ only sent when the
203
342
  resolved mode actually confines. If the mode is `danger-full-access`, or
204
343
  cannot be resolved at all (no `sandboxPolicy` service mounted, no agent
205
344
  session, a resolver that throws), the failure is left exactly as it was
@@ -218,14 +357,18 @@ the advisory never names the dead directory.
218
357
  refused twice — while a session can always make progress by spending the
219
358
  budget it has.
220
359
 
221
- **Why the blocking half does not extend to the PTY family** (it is
222
- ACL-only by construction, in the parameter type): the ACL remedy is a command
360
+ **Why the blocking half does not extend to the two mode-gated families** (it
361
+ is ACL-only by construction, in the parameter type): the ACL remedy is a
362
+ command
223
363
  the user can run *while the session continues*, so refusing further identical
224
364
  calls cannot make the session unfinishable — spending the budget always lets
225
365
  the call through, and a repaired environment is discovered by exactly that.
226
- The PTY remedy is a preset swap, which happens **between** sessions;
227
- refusing calls there could only pad a session that is already unable to do
228
- the thing being refused.
366
+ The PTY remedy is a preset swap, which happens **between** sessions, and the
367
+ native-init remedy is a launch fix on the user's side — whose one in-session
368
+ part, rewriting an MSYS2 command, the model does by calling a *different*
369
+ command, which has a different call key and is therefore never the call being
370
+ refused. Refusing calls in those families could only pad a session that is
371
+ already unable to do the thing being refused.
229
372
 
230
373
  ## Install
231
374
 
@@ -290,6 +433,15 @@ than one that stays silent.
290
433
  - **A successful command whose *output* contains the line is not a failure.**
291
434
  The gate is the result's error state, not the presence of the text — reading a
292
435
  log file that quotes the error must not trigger advice.
436
+ - **Only `STATUS_DLL_INIT_FAILED` is classified, and only from the canonical
437
+ value.** `0xC0000409` is the Cygwin/MSYS2 runtime's deliberate fast-fail (a
438
+ different mechanism with a different story) and `0xC0000135` is a missing DLL
439
+ (a packaging problem, not a sandbox one); both are refused, as is exit `127`,
440
+ which upstream's runner-failure rule already owns. And because the code is
441
+ compared as a number in `ToolExecutionSuccess.value`, a command printing
442
+ `[exit code: -1073741502]`, or any value that is not the shipped shell
443
+ projection (`kind: 'foreground'`), is not this family — a line of text can
444
+ never be mistaken for a loader status.
293
445
  - **Only one advisory per agent, per family.** The environment is explained
294
446
  once; repeating it per failed command would be noise competing with the
295
447
  failure itself.
@@ -298,7 +450,9 @@ than one that stays silent.
298
450
 
299
451
  - **The Windows path itself cannot be witnessed on macOS**, where this plugin
300
452
  was built. What the test suite proves is the decision layer — classification
301
- of both families, the once-per-agent-per-family rule, the sandbox-mode gate
453
+ of all three families (the third from the producer's own canonical value,
454
+ built by the suite with the reported `-1073741502` and the report's stderr
455
+ line), the once-per-agent-per-family rule, the sandbox-mode gate
302
456
  and its fail-closed behaviour, the fail-fast budget and its self-feeding
303
457
  guard, and the wiring to a real cordis `Context` and the real `ToolRuntime` —
304
458
  driven by fixtures that throw the producers' exact error shapes
@@ -307,7 +461,8 @@ than one that stays silent.
307
461
  does **not** prove that `icacls ... :(OI)(CI)F` fixes a given machine, nor that
308
462
  a given Windows host reproduces the PTY startup failure; those are the user's
309
463
  one-line experiment and the reporter's own control, and both advisories say
310
- where they stop.
464
+ where they stop. The native-init family is the one that needs no Windows to be
465
+ faithful, because what it reads is a number inside a JSON value.
311
466
  - **It repairs nothing and elevates nothing.** If the directory really is
312
467
  Full-control for the caller, the remaining ACL hypothesis is
313
468
  `SeSecurityPrivilege` — i.e. the backend's documented prerequisite would be
@@ -327,13 +482,16 @@ than one that stays silent.
327
482
  outside the seam this plugin subscribes to. The report and its proposed fix
328
483
  stay with the maintainers; all this plugin can do is explain the provisioning
329
484
  failure that shares its root.
330
- - **The real fix is upstream, in both families.** For the ACL failure,
485
+ - **The real fix is upstream, in all three families.** For the ACL failure,
331
486
  `grantWrite` already computes `hasExactGrant` / `hasExactDeny` /
332
487
  `hasExactLabel` and discards which one was false, so the diagnostic that turns
333
488
  a 52-minute detour into one line belongs at that site. For the PTY failure,
334
489
  the startup path should either report "this sandbox mode is incompatible with
335
- the PTY backend" or fall back to a one-shot shell. This plugin is the stopgap
336
- for both.
490
+ the PTY backend" or fall back to a one-shot shell. For the native-init death,
491
+ the runner should be launched with the environment its own execution needs
492
+ (`ELECTRON_RUN_AS_NODE=1` when `argv[0]` is an Electron binary — [`#7876`]'s
493
+ three candidate fixes) or refuse, in a checkable way, an MSYS2 program under a
494
+ restricted token. This plugin is the stopgap for all three.
337
495
 
338
496
  ## Compatibility
339
497
 
@@ -366,11 +524,16 @@ the newest of that line.
366
524
 
367
525
  The whole set is re-probed whenever this package's source changes rather than
368
526
  carried over from an earlier version: the range is a claim about *this* build of
369
- the plugin, so `0.4.0` re-ran all five lines above. A line whose probe fails is
527
+ the plugin, so `0.6.0` re-ran all five lines above. A line whose probe fails is
370
528
  removed from the range rather than left claimed. The scratch tree's resolved
371
529
  versions are the ones to read back when a probe is quoted as evidence — the probe
372
530
  script pins them by exact version, and `--keep` leaves the tree in place to check.
373
531
 
532
+ The mode lookup stays guarded through this version too: the native-init family
533
+ needs the same resolved mode as the PTY family, and it takes it from the same
534
+ `ctx.get('sandboxPolicy')` capability guard, so a composition without the service
535
+ degrades to a disclosed silence rather than failing to load.
536
+
374
537
  ## Development
375
538
 
376
539
  ```sh
@@ -387,8 +550,9 @@ refuses a call that would have worked, is worse than one that stays silent.
387
550
  `test:inject` exists because an arm nobody has seen fail proves nothing. It
388
551
  mutates the decision layer one defect at a time — the mode gate removed, the
389
552
  PTY message matched as a substring, the preset remedy pointed back at the dead
390
- legacy directory, the two families collapsed into one bookkeeping slot, the
391
- advisory delivered per call instead of per agent — and requires that specific
553
+ legacy directory, two families collapsed into one bookkeeping slot, the advisory
554
+ delivered per call instead of per agent, the loader-status read moved onto the
555
+ error path, the family keyed on the rendered text — and requires that specific
392
556
  arms fail. It reports `SILENT ARMS: none` when every arm bites, restores the
393
557
  source in a `finally`, and prints `EQUIVALENT` (with the reason) for a mutation
394
558
  the current runtime cannot distinguish rather than counting it as a pass.
@@ -399,4 +563,14 @@ the current runtime cannot distinguish rather than counting it as a pass.
399
563
  [discussion #7720]: https://github.com/deepseek-ai/deepseek-harness/discussions/7720
400
564
  [discussion #7750]: https://github.com/deepseek-ai/deepseek-harness/discussions/7750
401
565
  [discussion #7735]: https://github.com/deepseek-ai/deepseek-harness/discussions/7735
566
+ [discussion #7771]: https://github.com/deepseek-ai/deepseek-harness/discussions/7771
567
+ [discussion #7804]: https://github.com/deepseek-ai/deepseek-harness/discussions/7804
568
+ [discussion #7816]: https://github.com/deepseek-ai/deepseek-harness/discussions/7816
402
569
  [discussion #7638]: https://github.com/deepseek-ai/deepseek-harness/discussions/7638
570
+ [discussion #7876]: https://github.com/deepseek-ai/deepseek-harness/discussions/7876
571
+ [discussion #7877]: https://github.com/deepseek-ai/deepseek-harness/discussions/7877
572
+ [#7750]: https://github.com/deepseek-ai/deepseek-harness/discussions/7750
573
+ [#7771]: https://github.com/deepseek-ai/deepseek-harness/discussions/7771
574
+ [#7804]: https://github.com/deepseek-ai/deepseek-harness/discussions/7804
575
+ [#7876]: https://github.com/deepseek-ai/deepseek-harness/discussions/7876
576
+ [#7877]: https://github.com/deepseek-ai/deepseek-harness/discussions/7877