@argszero/cordis-plugin-sandbox-grant-advisor 0.9.1 → 0.11.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 +287 -24
- package/cordis.patch.yml +82 -4
- package/lib/advice.js +322 -12
- package/lib/index.js +128 -24
- package/lib/mode.js +60 -8
- package/lib/signature.js +202 -1
- package/lib/types/advice.d.ts +107 -9
- package/lib/types/index.d.ts +48 -9
- package/lib/types/mode.d.ts +34 -0
- package/lib/types/signature.d.ts +189 -3
- package/package.json +2 -2
package/lib/advice.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
|
|
6
|
+
* by a test, one family at a time. The four families are shaped by the same
|
|
7
7
|
* question — is this the sandbox's doing, and what can the reader do about it —
|
|
8
8
|
* and they answer it differently:
|
|
9
9
|
*
|
|
@@ -56,13 +56,35 @@
|
|
|
56
56
|
* keeps the token lowering yields a workspace the confined child cannot write
|
|
57
57
|
* to. That is stated instead of a bare "no", because a reader told only "no"
|
|
58
58
|
* reaches for the workaround without knowing what else it would have to change.
|
|
59
|
+
*
|
|
60
|
+
* **It also states what the grant leaves behind** (`#8312`, `#8314`), because
|
|
61
|
+
* this is the module that hands over the command applying that grant, and these
|
|
62
|
+
* are facts a reader needs at that moment rather than from a broken build in
|
|
63
|
+
* another project later. The three entries are standing by design — the
|
|
64
|
+
* backend's dispose path leaves them, and its own failure-cleanup comment calls
|
|
65
|
+
* them "the intended end state (the reuse cache)" — the Low label is
|
|
66
|
+
* inheritable and lives in the SACL (so resetting the DACL does not remove it),
|
|
67
|
+
* and an NTFS hard link is a second name for one file object, so a pnpm
|
|
68
|
+
* workspace's `node_modules` → content-addressed-store links carry that label
|
|
69
|
+
* out of the tree and leave it on objects other projects build from. The
|
|
70
|
+
* section offers no removal command: the maintainers' own diagnosis skill
|
|
71
|
+
* reports the label and leaves it, removing one needs `WRITE_OWNER`, and this
|
|
72
|
+
* project has no Windows host on which to verify a line.
|
|
59
73
|
* - **The persistent-shell failure** (`pty-startup`) is *not* fixable by the
|
|
60
74
|
* caller — least of all by the model, which has no shell to run anything in.
|
|
61
75
|
* So its advice says so and stops: the remedy is a user-side preset choice,
|
|
62
76
|
* and the model's instruction is to stop retrying and use its file tools.
|
|
63
77
|
* Handing the model a command here would be advice to run something that
|
|
64
78
|
* cannot run, and naming a one-shot shell tool would be advice to call a tool
|
|
65
|
-
* the failing composition does not mount.
|
|
79
|
+
* the failing composition does not mount. **Inside a confining mode the host
|
|
80
|
+
* binary decides**, which `#8322` separated with one runner and one ConPTY in
|
|
81
|
+
* every arm: a console-subsystem `node.exe` host starts the confined shell,
|
|
82
|
+
* while the packaged desktop's GUI-subsystem Electron host kills it silently.
|
|
83
|
+
* That is the same console rule the native-init family states — under the
|
|
84
|
+
* restricted token a console can be inherited but not created — so the advisory
|
|
85
|
+
* names the host alongside the mode, says the outcome is deterministic per
|
|
86
|
+
* (session mode × host) rather than intermittent, and offers the console-owning
|
|
87
|
+
* host as a user-side option `#8313` measured working.
|
|
66
88
|
* - **The native-init death** (`native-init`) is the one whose remedy is **split**:
|
|
67
89
|
* the *class* is not the model's to fix, but one of its two measured producers
|
|
68
90
|
* is. A confined child that died with `STATUS_DLL_INIT_FAILED` never ran
|
|
@@ -90,6 +112,31 @@
|
|
|
90
112
|
* want of that variable. A withdrawn cause earns its sentence because this
|
|
91
113
|
* advisory shipped it twice; a confidently wrong cause is worse than two named
|
|
92
114
|
* ones with one shared remedy.
|
|
115
|
+
* - **The workspace-internal denial** (`workspace-denial`, #423) is the fourth,
|
|
116
|
+
* and it is the first whose remedy is **withheld on purpose**. The failure is
|
|
117
|
+
* not that a command failed but that the workspace's own grant does not cover
|
|
118
|
+
* part of the workspace: the grant is written once on the root and depends on
|
|
119
|
+
* ACE inheritance, so an already-existing object whose DACL the caller could
|
|
120
|
+
* not write kept its older DACL, and the backend's root-only `hasExactGrant`
|
|
121
|
+
* check means it is never revisited. Retrying is therefore provably useless,
|
|
122
|
+
* which is what the text leads with. What it does **not** do is print a repair
|
|
123
|
+
* command: the obvious one (`icacls` recursing the capability SID) is refused
|
|
124
|
+
* by Windows itself with `ERROR_NONE_MAPPED` (1332), because that SID has no
|
|
125
|
+
* name to map, and the line that would work needs the right that is missing —
|
|
126
|
+
* so the advisory states the mechanism, names the ceiling, and says out loud
|
|
127
|
+
* that it prints no command because this project has no Windows host to verify
|
|
128
|
+
* one on. That is the same standard the standing-grant section is held to, and
|
|
129
|
+
* it is the reason this family can be useful without being prescriptive.
|
|
130
|
+
* Its discriminator is the **two-sided** one, which is not obvious and which a
|
|
131
|
+
* reader checking only the ACE would get wrong: reading and listing use the
|
|
132
|
+
* normal token while writing and deleting use the restricted one, so an object
|
|
133
|
+
* whose DACL names only `Administrators`/`SYSTEM` plus the capability SID is
|
|
134
|
+
* refused on the read side too and looks granted to a grep for the SID
|
|
135
|
+
* (#423 measured it). The variant that would otherwise be missed gets its own
|
|
136
|
+
* sentence as well — the same shape with the missing coverage on the mandatory
|
|
137
|
+
* label instead of the DACL (reported 2026-09-29 in the same thread), which is
|
|
138
|
+
* indistinguishable from inside a session and separable by the repository's own
|
|
139
|
+
* diagnosis skill.
|
|
93
140
|
*
|
|
94
141
|
* Both give a **discriminator, not just a remedy**: applying a fix without
|
|
95
142
|
* confirming the cause teaches nothing when the fix does not work. For the ACL
|
|
@@ -104,16 +151,72 @@
|
|
|
104
151
|
* wrong remedy. Inside that Electron answer there is a third thing the code
|
|
105
152
|
* cannot separate, and the advisory deliberately does not try: it names both
|
|
106
153
|
* measurements and says the remedy does not depend on choosing between them.
|
|
154
|
+
* For the workspace-denial family the discriminator is the **path**, which is
|
|
155
|
+
* why the advisory prints the path it keyed on beside the root it tested it
|
|
156
|
+
* against: the reader can audit the plugin's own reasoning instead of taking a
|
|
157
|
+
* claim about two strings on faith. That family's check is also the one place
|
|
158
|
+
* where a single fact is not enough — reachability has two sides, and the text
|
|
159
|
+
* says which one a check on the ACE alone would miss.
|
|
107
160
|
*
|
|
108
161
|
* @module
|
|
109
162
|
*/
|
|
110
163
|
import { failureLine, STATUS_DLL_INIT_FAILED } from './signature.js';
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The upstream threads the ACL advisory is a stopgap for.
|
|
166
|
+
*
|
|
167
|
+
* The first twelve report the provisioning failure itself (the merged DACL +
|
|
168
|
+
* label write being refused). The last two, `#8312` and `#8314`, report the
|
|
169
|
+
* other end of the same backend — what its grant leaves behind once it
|
|
170
|
+
* *succeeds* — which the advisory states because it is the fact a reader needs
|
|
171
|
+
* at the moment it hands them the command that applies that grant.
|
|
172
|
+
*/
|
|
173
|
+
export const ACL_DISCUSSIONS = '#7538 / #7622 / #7646 / #7720 / #7750 / #7735 / #7771 / #7804 / #7816 / #8232 / #8272 / #8275 / #8312 / #8314';
|
|
174
|
+
/**
|
|
175
|
+
* The upstream threads the persistent-shell advisory is a stopgap for.
|
|
176
|
+
*
|
|
177
|
+
* `#7638` is the failure and its three-arm control (the mode is the
|
|
178
|
+
* discriminator); `#8322` is the one that separated the arms inside a confining
|
|
179
|
+
* mode and found the sandbox runner's host binary — same runner, same ConPTY,
|
|
180
|
+
* console-subsystem `node.exe` host works where a GUI-subsystem one dies
|
|
181
|
+
* silently — which is why the advisory names the host as well as the mode.
|
|
182
|
+
*/
|
|
183
|
+
export const PTY_DISCUSSIONS = '#7638 / #8322';
|
|
184
|
+
/**
|
|
185
|
+
* The upstream threads the native-init-death advisory is a stopgap for.
|
|
186
|
+
*
|
|
187
|
+
* `#8313` is the fifth report of the same code and the one that states the host
|
|
188
|
+
* difference from the outside: the desktop build fails where the same version
|
|
189
|
+
* launched from a terminal does not, which is the same variable the PTY family
|
|
190
|
+
* now names.
|
|
191
|
+
*/
|
|
192
|
+
export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193 / #8208 / #8313';
|
|
193
|
+
/**
|
|
194
|
+
* The upstream thread the workspace-denial advisory is a stopgap for.
|
|
195
|
+
*
|
|
196
|
+
* One thread, because this family is one report and its own follow-up: `#423`
|
|
197
|
+
* is the failure and its measurements (170 of 729 objects missing the grant,
|
|
198
|
+
* root-level files among them, and the two-sided reachability rule), and the
|
|
199
|
+
* second comment in that thread is the mandatory-label variant of the same
|
|
200
|
+
* shape.
|
|
201
|
+
*/
|
|
202
|
+
export const WORKSPACE_DENIAL_DISCUSSIONS = '#423';
|
|
203
|
+
/**
|
|
204
|
+
* The thread list each family's withholding note cites.
|
|
205
|
+
*
|
|
206
|
+
* A withheld recognition is a decision the host log has to account for, and the
|
|
207
|
+
* note a maintainer reads is only useful if it points at *that* family's
|
|
208
|
+
* report — a withheld native-init death and a withheld workspace denial are
|
|
209
|
+
* different reports, and a note that cites the wrong one is its own small
|
|
210
|
+
* misdiagnosis. Kept beside the constants it assembles rather than at the
|
|
211
|
+
* withholding site, so a family added without a thread is a type error instead
|
|
212
|
+
* of a note that quietly cites another family's.
|
|
213
|
+
*/
|
|
214
|
+
export const DISCUSSIONS_OF = {
|
|
215
|
+
'acl-provisioning': ACL_DISCUSSIONS,
|
|
216
|
+
'pty-startup': PTY_DISCUSSIONS,
|
|
217
|
+
'native-init': NATIVE_INIT_DISCUSSIONS,
|
|
218
|
+
'workspace-denial': WORKSPACE_DENIAL_DISCUSSIONS,
|
|
219
|
+
};
|
|
117
220
|
/** The documented prerequisite, quoted from the backend's README. */
|
|
118
221
|
export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
|
|
119
222
|
/**
|
|
@@ -232,7 +335,9 @@ function nonFixes(failure, path) {
|
|
|
232
335
|
' supplies the DACL half and still not WRITE_OWNER, the right this call needs. In the second branch above',
|
|
233
336
|
' it is a legitimate first step with elevation; it is never the fix by itself.',
|
|
234
337
|
` icacls "${path}" /reset /T /C`,
|
|
235
|
-
' restores inheritance, and inheritance is what supplied the Modify-only ACE above.',
|
|
338
|
+
' restores inheritance, and inheritance is what supplied the Modify-only ACE above. Where it strips the last',
|
|
339
|
+
' entry naming you, the directory ends up in exactly the state the diagnosis above describes — its only access',
|
|
340
|
+
' the inherited `Authenticated Users:(M)` (#8314 measured that follow-on failure).',
|
|
236
341
|
'',
|
|
237
342
|
];
|
|
238
343
|
}
|
|
@@ -307,6 +412,76 @@ function degradedGrant() {
|
|
|
307
412
|
'This is not offered here as a fix, and neither is `danger-full-access`.',
|
|
308
413
|
];
|
|
309
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* What the grant leaves behind once it applies, and how far its label travels.
|
|
417
|
+
*
|
|
418
|
+
* The advisory hands the reader a command that makes the backend's workspace
|
|
419
|
+
* grant succeed. This section says what that grant *is* from the other side —
|
|
420
|
+
* not as a warning against running it (without it nothing sandboxed runs at
|
|
421
|
+
* all) but because the effect outlives the session and reaches outside the
|
|
422
|
+
* workspace, and a reader who learns that from a broken build three projects
|
|
423
|
+
* later has learned it too late. `#8312` collected those far-away symptoms;
|
|
424
|
+
* `#8314` measured how the label gets there.
|
|
425
|
+
*
|
|
426
|
+
* Every claim is read off the shipped source rather than repeated from the
|
|
427
|
+
* reports: the standing edits and the dispose path that leaves them
|
|
428
|
+
* (`src/grant.ts`, whose `dispose` doc says the standing edits are skipped by
|
|
429
|
+
* design, and `src/index.ts`, whose fail-closed cleanup says the same in
|
|
430
|
+
* plainer words); the label's `(OI|CI)` inheritance and its home in the SACL
|
|
431
|
+
* (`src/acl.ts`, the merged security-information flags); and the hard-link
|
|
432
|
+
* boundary, which the backend's own suite pins as a known reach
|
|
433
|
+
* (`tests/runner.spec.ts`, "a workspace hard link lets the grant reach an
|
|
434
|
+
* external file object").
|
|
435
|
+
*
|
|
436
|
+
* Two things it deliberately does **not** do. It does not offer a removal
|
|
437
|
+
* command: the maintainers' own diagnosis skill reports the label and leaves it,
|
|
438
|
+
* removing one needs `WRITE_OWNER`, and this project has no Windows host to
|
|
439
|
+
* verify a line on — shipping an unverified removal command would be the same
|
|
440
|
+
* defect the rest of this module exists to answer. And it does not present the
|
|
441
|
+
* out-of-tree reach as universally true: it is what happens when the workspace
|
|
442
|
+
* contains hard links into a store on the same volume, which is what a pnpm
|
|
443
|
+
* install produces and what the report measured.
|
|
444
|
+
*
|
|
445
|
+
* Emitted for every class, like {@link versionBoundary} and for the same reason:
|
|
446
|
+
* the fact is about the package's grant (and the remedy the advisory hands over
|
|
447
|
+
* in every class is that grant), not about which of its two calls failed.
|
|
448
|
+
* @returns the section's lines, ending with the blank separator line.
|
|
449
|
+
*/
|
|
450
|
+
function standingEdits() {
|
|
451
|
+
return [
|
|
452
|
+
'What the grant leaves behind, once it applies — worth knowing before you run the command above, because the',
|
|
453
|
+
'backend does not take it back:',
|
|
454
|
+
' The three entries are STANDING, deliberately, and nothing revokes them. The workspace grant is a reuse cache:',
|
|
455
|
+
' the dispose path is documented to revoke the revocable (temp) grants and leave "the standing workspace edits',
|
|
456
|
+
' in place", and the failure-cleanup path says it in plainer words — standing ACEs "are NOT revoked — they are',
|
|
457
|
+
' the intended end state (the reuse cache), not an error artifact". They outlive the session and the harness',
|
|
458
|
+
' exiting (`sandbox-windows-acl/src/grant.ts`, `src/index.ts`).',
|
|
459
|
+
' The Low integrity label is INHERITABLE (`(OI|CI)`) and it lives in the SACL — which is why the `icacls',
|
|
460
|
+
' /reset` above does not take it off: that command rebuilds the DACL. Windows starts a process at the minimum',
|
|
461
|
+
' of the user\'s and the program\'s integrity, so anything started from a tree the harness has written to runs at',
|
|
462
|
+
' LOW integrity, and none of the symptoms names DSH (#8312 collects them): an Electron/Chromium app exiting',
|
|
463
|
+
' `0x80000003` at startup with no output (#7709), msbuild / dotnet / npm refusing or warning about the files as',
|
|
464
|
+
' if they came from the Internet when no `Zone.Identifier` exists (#8175), a double-clicked `.exe` / `.cmd`',
|
|
465
|
+
' reporting "publisher could not be verified" (#7735).',
|
|
466
|
+
' It can also leave the workspace. An NTFS hard link is a SECOND NAME for one file object, so both names share',
|
|
467
|
+
' one security descriptor — and a pnpm workspace is largely hard links (`node_modules` pointing into a',
|
|
468
|
+
' content-addressed store on the same volume). An inheritable label written inside the tree therefore lands on',
|
|
469
|
+
' the STORE\'s objects and stays there, after which every project using that store builds with executables that',
|
|
470
|
+
' start at Low integrity, and the failure surfaces as the build tool rather than the sandbox: `vite build` unable',
|
|
471
|
+
' to remove its own temp file, `pnpm install` unable to replace a hook (#8314 measured the whole chain). The',
|
|
472
|
+
' backend\'s own suite pins the link reach as a known boundary — "a workspace hard link lets the grant reach an',
|
|
473
|
+
' external file object" — and its README calls refusing multiply-linked files unviable for ordinary pnpm',
|
|
474
|
+
' installs, which leaves the out-of-tree reach open rather than unknown.',
|
|
475
|
+
' It is also why the label is not simply removable here: the built-in `diagnose-windows-sandbox-acl` skill',
|
|
476
|
+
' (0.2.0 and later) reports `LOW_LABEL` and by design does not remove it, and removing an integrity label needs',
|
|
477
|
+
' WRITE_OWNER — the same right this whole failure is about. This advisory hands over no removal command: the',
|
|
478
|
+
' maintainers\' own skill does not, and an unverified one would be the defect this plugin exists to answer.',
|
|
479
|
+
'None of this makes the command above the wrong move — without it, nothing sandboxed runs in this workspace. It',
|
|
480
|
+
'is what the harness does to a directory it has been pointed at, and it is worth knowing before rather than',
|
|
481
|
+
'discovering it as a broken build in some other project later.',
|
|
482
|
+
'',
|
|
483
|
+
];
|
|
484
|
+
}
|
|
310
485
|
/**
|
|
311
486
|
* Build the advisory attached to the failing tool result.
|
|
312
487
|
*
|
|
@@ -331,6 +506,8 @@ export function advisoryText(failure, context = {}) {
|
|
|
331
506
|
}
|
|
332
507
|
return nativeInitAdvisory(failure, context.mode, context.electronHost ?? electronHost(), context.href);
|
|
333
508
|
}
|
|
509
|
+
if (failure.family === 'workspace-denial')
|
|
510
|
+
return workspaceDenialAdvisory(failure, context.tool, context.href);
|
|
334
511
|
return aclAdvisory(failure, context.href);
|
|
335
512
|
}
|
|
336
513
|
/**
|
|
@@ -395,6 +572,7 @@ function aclAdvisory(failure, href) {
|
|
|
395
572
|
'inherits Full control for you — and open the session on that one.',
|
|
396
573
|
'',
|
|
397
574
|
...nonFixes(failure, path),
|
|
575
|
+
...standingEdits(),
|
|
398
576
|
...(failure.klass === 'apply-denied' ? [...degradedGrant(), ''] : []),
|
|
399
577
|
'How to read this: the harness documents the prerequisite (' + PREREQUISITE + ') and this',
|
|
400
578
|
'error does not name it yet, so the advice is delivered here instead. This is a stopgap, ' + where + '.',
|
|
@@ -542,7 +720,7 @@ function nativeInitAdvisory(failure, mode, onElectron, href) {
|
|
|
542
720
|
* @returns the user-role notice text.
|
|
543
721
|
*/
|
|
544
722
|
function ptyAdvisory(failure, mode, tool, href) {
|
|
545
|
-
const where = href === undefined ? `tracked upstream (
|
|
723
|
+
const where = href === undefined ? `tracked upstream (discussions ${PTY_DISCUSSIONS})` : `tracked upstream: ${href}`;
|
|
546
724
|
const call = tool === undefined ? 'This tool' : `The \`${tool}\` tool`;
|
|
547
725
|
return [
|
|
548
726
|
'Persistent shell failed to start — command execution is unavailable in this session, and retrying cannot fix it.',
|
|
@@ -556,6 +734,24 @@ function ptyAdvisory(failure, mode, tool, href) {
|
|
|
556
734
|
'shell works under `danger-full-access`, and the one-shot shell tool works under the same confining mode:',
|
|
557
735
|
'persistent PTY × confining sandbox is the combination that fails.',
|
|
558
736
|
'',
|
|
737
|
+
'Which sessions fail inside that combination is not chance — it is deterministic per (session mode × the host',
|
|
738
|
+
'binary carrying the sandbox runner), so a "working now" attempt in the same session is not evidence of flakiness.',
|
|
739
|
+
'Measured against the desktop build with the same runner and the same ConPTY in every arm (#8322):',
|
|
740
|
+
' - runner hosted by a plain console-subsystem `node.exe` → the confined shell starts, prompt and shell-integration',
|
|
741
|
+
' marks correct;',
|
|
742
|
+
' - runner hosted by the packaged desktop\'s GUI-subsystem Electron executable (started with',
|
|
743
|
+
' `ELECTRON_RUN_AS_NODE=1`) → the child dies silently: zero bytes on stdout AND stderr, and the non-interactive',
|
|
744
|
+
' arm exits 0 with everything it printed lost.',
|
|
745
|
+
'The rule behind both this and the `0xC0000142` family is the one stated there for its own case: under the',
|
|
746
|
+
'restricted token a console can be INHERITED but not CREATED — so the host binary, the thing that owns a console',
|
|
747
|
+
'or owns none, is what the arms above turn on. It is also why the same build behaves differently depending on how',
|
|
748
|
+
'it was started: the same version run as the desktop app fails, while the Web UI started from a terminal —',
|
|
749
|
+
'whose `process.execPath` is a real `node.exe` — is reported working under the same confining mode (#8313).',
|
|
750
|
+
'And the mode that decides is the one the SESSION records, not the one the environment now holds: a session',
|
|
751
|
+
'whose stream recorded the confining mode keeps failing',
|
|
752
|
+
'after the app is restarted with a different mode in the environment, while switching it inside that session',
|
|
753
|
+
'takes effect immediately (#8322).',
|
|
754
|
+
'',
|
|
559
755
|
'Do NOT retry, and do not look for a command that fixes it: every attempt will fail identically, and there is no',
|
|
560
756
|
'shell to run a command in. Use your file read/write tools instead, and hand the choice below to the user.',
|
|
561
757
|
'',
|
|
@@ -566,14 +762,128 @@ function ptyAdvisory(failure, mode, tool, href) {
|
|
|
566
762
|
` \`${GLOBAL_PATCH}\` for every profile — replacing its \`persistent-shell\` group with`,
|
|
567
763
|
` \`${ONE_SHOT_SHELL}\` (a one-shot subprocess, no PTY); the patch layer is yours, so an upgrade`,
|
|
568
764
|
' will not overwrite it; or',
|
|
569
|
-
' 3. run the session
|
|
570
|
-
'
|
|
765
|
+
' 3. run the session from a host that owns a console instead of the packaged desktop app — the Web UI started',
|
|
766
|
+
' from a terminal (`process.execPath` is a real `node.exe` there) was reported working under the same',
|
|
767
|
+
' confining mode and the same version (#8313); or',
|
|
768
|
+
' 4. run the session with `danger-full-access`, which drops the very confinement the sandbox exists to give.',
|
|
769
|
+
' Prefer 1 to 3.',
|
|
571
770
|
'',
|
|
572
771
|
'How to read this: the failure names no cause and points at no remedy, so the diagnosis is delivered here instead.',
|
|
573
772
|
'This is a stopgap, ' + where + '. Unless the mode is `danger-full-access`, this plugin stays silent, because a',
|
|
574
773
|
'shell can fail to start for other reasons and a confident wrong cause is worse than no answer.',
|
|
575
774
|
].join('\n');
|
|
576
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* Build the advisory for a confined command that was denied a path inside its
|
|
778
|
+
* own workspace.
|
|
779
|
+
*
|
|
780
|
+
* Four things this text must do, and one it must not. It must lead with the
|
|
781
|
+
* fact that **retrying is provably useless** (the backend's provisioning check
|
|
782
|
+
* short-circuits on the root, so the object that missed the propagation is never
|
|
783
|
+
* revisited) — that is the whole reason the model needs telling rather than
|
|
784
|
+
* discovering. It must show the **path it keyed on and the root it tested it
|
|
785
|
+
* against**, because the family's claim is a statement about two strings and the
|
|
786
|
+
* reader is entitled to audit it. It must give the **two-sided** reachability
|
|
787
|
+
* rule, since a check that only looks for the capability ACE reports an object
|
|
788
|
+
* as granted when the read side is refused as well. And it must name the
|
|
789
|
+
* **label variant** of the same shape, because from inside a session the two are
|
|
790
|
+
* indistinguishable and a reader who repairs the wrong half has learned
|
|
791
|
+
* nothing.
|
|
792
|
+
*
|
|
793
|
+
* What it must not do is print a repair command. The obvious one is refused by
|
|
794
|
+
* Windows with `ERROR_NONE_MAPPED` (1332) — a capability SID has no name for
|
|
795
|
+
* `icacls` to map — and the one that would work needs `WRITE_DAC` on the object,
|
|
796
|
+
* which is the right in question. This project has no Windows host to verify a
|
|
797
|
+
* line on, and the standing-grant section of the ACL advisory is held to the
|
|
798
|
+
* same standard for the same reason: an unverified remedy delivered confidently
|
|
799
|
+
* is the defect this plugin exists to answer. The mechanism is stated instead,
|
|
800
|
+
* and the reader is told why the command is absent.
|
|
801
|
+
* @param failure - the recognized failure.
|
|
802
|
+
* @param tool - the tool whose call was denied, when the caller knows it.
|
|
803
|
+
* @param href - optional URL shown for the upstream thread.
|
|
804
|
+
* @returns the user-role notice text.
|
|
805
|
+
*/
|
|
806
|
+
function workspaceDenialAdvisory(failure, tool, href) {
|
|
807
|
+
const where = href === undefined
|
|
808
|
+
? `tracked upstream (discussion ${WORKSPACE_DENIAL_DISCUSSIONS})`
|
|
809
|
+
: `tracked upstream: ${href}`;
|
|
810
|
+
const call = tool === undefined ? 'This command' : `The \`${tool}\` command`;
|
|
811
|
+
const subject = failure.paths[0] ?? '<the path from the error line above>';
|
|
812
|
+
return [
|
|
813
|
+
'Denied inside your own workspace — the workspace grant does not reach that part of the tree, and retrying cannot repair it.',
|
|
814
|
+
'',
|
|
815
|
+
'What was reported:',
|
|
816
|
+
` ${failureLine(failure)}`,
|
|
817
|
+
`${call} ran under sandbox mode \`${failure.mode}\`, where a write INSIDE the workspace is supposed to succeed, and every`,
|
|
818
|
+
'path it named is inside this session\'s workspace:',
|
|
819
|
+
...failure.paths.map(path => ` ${path}`),
|
|
820
|
+
` (workspace root: ${failure.workspaceRoot})`,
|
|
821
|
+
'So this is not the sandbox declining work that belongs outside the workspace. It is the workspace\'s own grant',
|
|
822
|
+
'failing to cover an object inside it, which is why every command touching that object fails the same way.',
|
|
823
|
+
'',
|
|
824
|
+
'Why — and why retrying cannot fix it:',
|
|
825
|
+
' The host-side grant is written ONCE, on the workspace ROOT, and relies on Windows ACE inheritance to reach',
|
|
826
|
+
' everything beneath it. Writing an inherited ACE into an ALREADY-EXISTING child needs WRITE_DAC on that child;',
|
|
827
|
+
' where the caller does not hold it, Windows skips the child silently — no error, no return value, no log line.',
|
|
828
|
+
' The backend then checks only the root (`hasExactGrant(workspaceRoot)`) and returns early when the grant is',
|
|
829
|
+
' already there, which it is from the first call onwards. The descendants that missed the propagation are',
|
|
830
|
+
' therefore never revisited — not later in this session, not in any later one — so the identical command keeps',
|
|
831
|
+
' failing and there is no number of attempts that changes that',
|
|
832
|
+
' (`packages/sandbox/sandbox-windows-acl/src/acl.ts`).',
|
|
833
|
+
' WHICH objects miss it is a fact about who created them: objects the harness itself creates inherit the ACE,',
|
|
834
|
+
' while objects that already existed or that another account or tool created (an installer, an editor, another',
|
|
835
|
+
' agent harness running under its own account) are the ones the propagation skipped. #423 measured 170 of 729',
|
|
836
|
+
' objects missing it, INCLUDING root-level files — so it is not only subdirectories, and "write it at the',
|
|
837
|
+
' workspace root instead" is not a safe move either.',
|
|
838
|
+
'',
|
|
839
|
+
'Confirm it — this is the discriminator, and the second half is the part a single check gets wrong:',
|
|
840
|
+
` icacls "${subject}"`,
|
|
841
|
+
'compared with the same command against the workspace root. The root carries an inheritable ACE for the workspace',
|
|
842
|
+
'capability SID — a `S-1-4-…` that `icacls` prints as an unresolved SID rather than a name — with Modify or Full',
|
|
843
|
+
'control; the failing object does not have it. Check each path listed above the same way; the first is shown here.',
|
|
844
|
+
' THE TRAP: reading and listing go through the NORMAL token, writing and deleting through the RESTRICTED',
|
|
845
|
+
' (low-integrity) one, and both sides must pass. An object whose DACL names only Administrators/SYSTEM plus the',
|
|
846
|
+
' capability SID is refused on the read side as well, so it looks granted to a check that only searches for the',
|
|
847
|
+
' capability SID — #423 measured exactly that on a `.cache` directory. A check that asks only "is the SID there?"',
|
|
848
|
+
' reports those objects as fine while LIST and WRITE are both denied.',
|
|
849
|
+
' A SECOND measured variant has the same shape and a different object: the coverage that is missing can be the',
|
|
850
|
+
' mandatory-integrity LABEL rather than a DACL entry. It was reported in this same thread on 2026-09-29 against a',
|
|
851
|
+
' `0.2.0-rc.1` install, under the same root-only short-circuit. Inside a session the two are indistinguishable;',
|
|
852
|
+
' from outside, the repository\'s own diagnosis skill separates them — `diagnose-windows-sandbox-acl` (0.2.0 and',
|
|
853
|
+
' later) reports `hasExactDeny()` for the DACL half and `LOW_LABEL` (`S-1-16-4096`) for the label half.',
|
|
854
|
+
'',
|
|
855
|
+
'Why the natural repair is closed — this is a Windows ceiling, not a mistake in the command:',
|
|
856
|
+
' The grant the root carries is written for a capability SID, and a recursive `icacls /grant "*S-1-4-…:…" /T /C`',
|
|
857
|
+
' is refused with ERROR_NONE_MAPPED (1332): the tool cannot map that SID to a name, so a grant that needs to name',
|
|
858
|
+
' it never reaches the child. Widening the grant to a nameable account is a different grant with different',
|
|
859
|
+
' consequences, not this one restored.',
|
|
860
|
+
'',
|
|
861
|
+
'What helps:',
|
|
862
|
+
' - The move that is yours, and the only one available inside the session: write new files under a directory the',
|
|
863
|
+
' harness itself created in this workspace. Those carry the grant, because their inheritance did apply — the',
|
|
864
|
+
' reporter\'s own measurement, that objects DSH created are complete here and externally created ones are not.',
|
|
865
|
+
' - The user\'s move: repair the specific object from an account that already holds WRITE_DAC on it, by writing',
|
|
866
|
+
' the ACE the root carries into the child\'s own DACL. That needs the very right that is missing, so it is not',
|
|
867
|
+
' something an unelevated prompt can do.',
|
|
868
|
+
' No repair command is printed here on purpose. This project has no Windows host to verify one on, and shipping',
|
|
869
|
+
' an unverified line would be the same defect this plugin exists to answer. The mechanism above is what is known;',
|
|
870
|
+
' the line is yours to choose.',
|
|
871
|
+
'',
|
|
872
|
+
'The retry you are about to be offered: the denial surface offers one retry of this exact command under a wider',
|
|
873
|
+
'mode. That retry can only succeed by removing the confinement itself — it repairs nothing in the workspace, and',
|
|
874
|
+
'the next session meets the same gap. Take it only if that is what you mean to buy.',
|
|
875
|
+
'',
|
|
876
|
+
'Do NOT retry this call unchanged: the provisioning path short-circuits on the root grant, so the object that was',
|
|
877
|
+
'skipped is never revisited.',
|
|
878
|
+
'',
|
|
879
|
+
'How to read this: the denial names no cause and points at no repair, so the diagnosis is delivered here instead.',
|
|
880
|
+
'This is a stopgap, ' + where + '. This plugin speaks only when the mode is `workspace-write`, the host is',
|
|
881
|
+
'Windows, and every path the command names lies inside the workspace — a denial anywhere else is the sandbox',
|
|
882
|
+
'working as designed, and a confident wrong cause is worse than no answer.',
|
|
883
|
+
'What it is NOT: this plugin does not edit an ACL, does not elevate, and does not offer `danger-full-access` as a',
|
|
884
|
+
'fix.',
|
|
885
|
+
].join('\n');
|
|
886
|
+
}
|
|
577
887
|
/**
|
|
578
888
|
* Build the pre-dispatch denial for the optional fail-fast half.
|
|
579
889
|
*
|