@argszero/cordis-plugin-sandbox-grant-advisor 0.10.0 → 0.12.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 +192 -25
- package/cordis.patch.yml +46 -2
- package/lib/advice.js +198 -8
- package/lib/index.js +128 -24
- package/lib/mode.js +60 -8
- package/lib/signature.js +202 -1
- package/lib/types/advice.d.ts +56 -3
- 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
|
*
|
|
@@ -112,6 +112,31 @@
|
|
|
112
112
|
* want of that variable. A withdrawn cause earns its sentence because this
|
|
113
113
|
* advisory shipped it twice; a confidently wrong cause is worse than two named
|
|
114
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.
|
|
115
140
|
*
|
|
116
141
|
* Both give a **discriminator, not just a remedy**: applying a fix without
|
|
117
142
|
* confirming the cause teaches nothing when the fix does not work. For the ACL
|
|
@@ -126,6 +151,12 @@
|
|
|
126
151
|
* wrong remedy. Inside that Electron answer there is a third thing the code
|
|
127
152
|
* cannot separate, and the advisory deliberately does not try: it names both
|
|
128
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.
|
|
129
160
|
*
|
|
130
161
|
* @module
|
|
131
162
|
*/
|
|
@@ -158,7 +189,34 @@ export const PTY_DISCUSSIONS = '#7638 / #8322';
|
|
|
158
189
|
* launched from a terminal does not, which is the same variable the PTY family
|
|
159
190
|
* now names.
|
|
160
191
|
*/
|
|
161
|
-
export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193 / #8208 / #8313';
|
|
192
|
+
export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193 / #8208 / #8313 / #8336 / #8334';
|
|
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
|
+
};
|
|
162
220
|
/** The documented prerequisite, quoted from the backend's README. */
|
|
163
221
|
export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
|
|
164
222
|
/**
|
|
@@ -448,6 +506,8 @@ export function advisoryText(failure, context = {}) {
|
|
|
448
506
|
}
|
|
449
507
|
return nativeInitAdvisory(failure, context.mode, context.electronHost ?? electronHost(), context.href);
|
|
450
508
|
}
|
|
509
|
+
if (failure.family === 'workspace-denial')
|
|
510
|
+
return workspaceDenialAdvisory(failure, context.tool, context.href);
|
|
451
511
|
return aclAdvisory(failure, context.href);
|
|
452
512
|
}
|
|
453
513
|
/**
|
|
@@ -622,12 +682,31 @@ function nativeInitAdvisory(failure, mode, onElectron, href) {
|
|
|
622
682
|
'Honest boundary — 0xC0000142 has producers this list does not have: a program that cannot load one of',
|
|
623
683
|
'its own DLLs dies this way too, and the backend\'s own source records the console case as an inherent',
|
|
624
684
|
'limit of the backend (`CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` children die with `STATUS_DLL_INIT_FAILED`',
|
|
625
|
-
'under the restriction). #8208 explains that limit instead of repeating it, and the explanation is
|
|
626
|
-
'
|
|
627
|
-
'
|
|
628
|
-
'
|
|
629
|
-
'
|
|
630
|
-
'
|
|
685
|
+
'under the restriction). #8208 explains that limit instead of repeating it, and the explanation is a single',
|
|
686
|
+
'rule: in a restricted token a console can be INHERITED but not CREATED. Both shapes above follow from it —',
|
|
687
|
+
'the child needs a console it did not create, and anything that forces it to CREATE one is fatal on its own.',
|
|
688
|
+
'#8336 measured that side directly, on one machine, with a console-owning host, a restricted token and the',
|
|
689
|
+
'Low integrity level, varying only the creation flags: `0`, `DETACHED_PROCESS` and `CREATE_NEW_PROCESS_GROUP`',
|
|
690
|
+
'all reached the program, while `CREATE_NO_WINDOW` and `CREATE_NEW_CONSOLE` both died with the code above.',
|
|
691
|
+
'`STARTF_USESHOWWINDOW` with `SW_HIDE` — how a window is hidden without isolating a console — was harmless,',
|
|
692
|
+
'and so was `CREATE_NO_WINDOW` on an UNRESTRICTED token, which is what makes the two a pair rather than a',
|
|
693
|
+
'list of forbidden flags.',
|
|
694
|
+
'The creation flags this harness actually passes are three sets and none of them is `CREATE_NO_WINDOW` —',
|
|
695
|
+
'`0` on the piped path, `CREATE_SUSPENDED` on the inherited-job path, and',
|
|
696
|
+
'`CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT` on the ordinary path (that constant is not defined anywhere',
|
|
697
|
+
'in the process source). Those three are fatal under a console-less host and harmless under a host that owns',
|
|
698
|
+
'a console, so there the console decides. A flag that forces creation is a different animal: it decides even',
|
|
699
|
+
'when a console is there to be inherited.',
|
|
700
|
+
'',
|
|
701
|
+
'One thing that gets suspected and is not the cause: `windowsHide`. It is named here because it is the',
|
|
702
|
+
'first thing a search turns up, and it points at the wrong component — the flag is not set anywhere on the',
|
|
703
|
+
'confined path above. It appears on the ORDINARY subprocess path (`dsh-subprocess-local`: `windowsHide:`',
|
|
704
|
+
'`platform === \'win32\'`), which starts the runner rather than the confined child, and the restricted spawn',
|
|
705
|
+
'in `dsh-win32-process` passes the three flag sets just listed and defines no `CREATE_NO_WINDOW` constant at',
|
|
706
|
+
'all. Where it IS set — on the host — #8208 measured it both ways on a host that works: with and without',
|
|
707
|
+
'`windowsHide`, the confined `pwsh` reached exit 0 with its own stdout intact. The reason is the rule again:',
|
|
708
|
+
'a windowless console is still a console, and that is what the child inherits. What decides is whether the',
|
|
709
|
+
'host owns a console OBJECT, not whether it owns a window.',
|
|
631
710
|
'',
|
|
632
711
|
'One arm of this family was applied, measured, and rejected — named so a reader does not reach for it:',
|
|
633
712
|
'putting `DETACHED_PROCESS` on the RESTRICTED CHILD removes its console request and does stop the crash,',
|
|
@@ -713,6 +792,117 @@ function ptyAdvisory(failure, mode, tool, href) {
|
|
|
713
792
|
'shell can fail to start for other reasons and a confident wrong cause is worse than no answer.',
|
|
714
793
|
].join('\n');
|
|
715
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* Build the advisory for a confined command that was denied a path inside its
|
|
797
|
+
* own workspace.
|
|
798
|
+
*
|
|
799
|
+
* Four things this text must do, and one it must not. It must lead with the
|
|
800
|
+
* fact that **retrying is provably useless** (the backend's provisioning check
|
|
801
|
+
* short-circuits on the root, so the object that missed the propagation is never
|
|
802
|
+
* revisited) — that is the whole reason the model needs telling rather than
|
|
803
|
+
* discovering. It must show the **path it keyed on and the root it tested it
|
|
804
|
+
* against**, because the family's claim is a statement about two strings and the
|
|
805
|
+
* reader is entitled to audit it. It must give the **two-sided** reachability
|
|
806
|
+
* rule, since a check that only looks for the capability ACE reports an object
|
|
807
|
+
* as granted when the read side is refused as well. And it must name the
|
|
808
|
+
* **label variant** of the same shape, because from inside a session the two are
|
|
809
|
+
* indistinguishable and a reader who repairs the wrong half has learned
|
|
810
|
+
* nothing.
|
|
811
|
+
*
|
|
812
|
+
* What it must not do is print a repair command. The obvious one is refused by
|
|
813
|
+
* Windows with `ERROR_NONE_MAPPED` (1332) — a capability SID has no name for
|
|
814
|
+
* `icacls` to map — and the one that would work needs `WRITE_DAC` on the object,
|
|
815
|
+
* which is the right in question. This project has no Windows host to verify a
|
|
816
|
+
* line on, and the standing-grant section of the ACL advisory is held to the
|
|
817
|
+
* same standard for the same reason: an unverified remedy delivered confidently
|
|
818
|
+
* is the defect this plugin exists to answer. The mechanism is stated instead,
|
|
819
|
+
* and the reader is told why the command is absent.
|
|
820
|
+
* @param failure - the recognized failure.
|
|
821
|
+
* @param tool - the tool whose call was denied, when the caller knows it.
|
|
822
|
+
* @param href - optional URL shown for the upstream thread.
|
|
823
|
+
* @returns the user-role notice text.
|
|
824
|
+
*/
|
|
825
|
+
function workspaceDenialAdvisory(failure, tool, href) {
|
|
826
|
+
const where = href === undefined
|
|
827
|
+
? `tracked upstream (discussion ${WORKSPACE_DENIAL_DISCUSSIONS})`
|
|
828
|
+
: `tracked upstream: ${href}`;
|
|
829
|
+
const call = tool === undefined ? 'This command' : `The \`${tool}\` command`;
|
|
830
|
+
const subject = failure.paths[0] ?? '<the path from the error line above>';
|
|
831
|
+
return [
|
|
832
|
+
'Denied inside your own workspace — the workspace grant does not reach that part of the tree, and retrying cannot repair it.',
|
|
833
|
+
'',
|
|
834
|
+
'What was reported:',
|
|
835
|
+
` ${failureLine(failure)}`,
|
|
836
|
+
`${call} ran under sandbox mode \`${failure.mode}\`, where a write INSIDE the workspace is supposed to succeed, and every`,
|
|
837
|
+
'path it named is inside this session\'s workspace:',
|
|
838
|
+
...failure.paths.map(path => ` ${path}`),
|
|
839
|
+
` (workspace root: ${failure.workspaceRoot})`,
|
|
840
|
+
'So this is not the sandbox declining work that belongs outside the workspace. It is the workspace\'s own grant',
|
|
841
|
+
'failing to cover an object inside it, which is why every command touching that object fails the same way.',
|
|
842
|
+
'',
|
|
843
|
+
'Why — and why retrying cannot fix it:',
|
|
844
|
+
' The host-side grant is written ONCE, on the workspace ROOT, and relies on Windows ACE inheritance to reach',
|
|
845
|
+
' everything beneath it. Writing an inherited ACE into an ALREADY-EXISTING child needs WRITE_DAC on that child;',
|
|
846
|
+
' where the caller does not hold it, Windows skips the child silently — no error, no return value, no log line.',
|
|
847
|
+
' The backend then checks only the root (`hasExactGrant(workspaceRoot)`) and returns early when the grant is',
|
|
848
|
+
' already there, which it is from the first call onwards. The descendants that missed the propagation are',
|
|
849
|
+
' therefore never revisited — not later in this session, not in any later one — so the identical command keeps',
|
|
850
|
+
' failing and there is no number of attempts that changes that',
|
|
851
|
+
' (`packages/sandbox/sandbox-windows-acl/src/acl.ts`).',
|
|
852
|
+
' WHICH objects miss it is a fact about who created them: objects the harness itself creates inherit the ACE,',
|
|
853
|
+
' while objects that already existed or that another account or tool created (an installer, an editor, another',
|
|
854
|
+
' agent harness running under its own account) are the ones the propagation skipped. #423 measured 170 of 729',
|
|
855
|
+
' objects missing it, INCLUDING root-level files — so it is not only subdirectories, and "write it at the',
|
|
856
|
+
' workspace root instead" is not a safe move either.',
|
|
857
|
+
'',
|
|
858
|
+
'Confirm it — this is the discriminator, and the second half is the part a single check gets wrong:',
|
|
859
|
+
` icacls "${subject}"`,
|
|
860
|
+
'compared with the same command against the workspace root. The root carries an inheritable ACE for the workspace',
|
|
861
|
+
'capability SID — a `S-1-4-…` that `icacls` prints as an unresolved SID rather than a name — with Modify or Full',
|
|
862
|
+
'control; the failing object does not have it. Check each path listed above the same way; the first is shown here.',
|
|
863
|
+
' THE TRAP: reading and listing go through the NORMAL token, writing and deleting through the RESTRICTED',
|
|
864
|
+
' (low-integrity) one, and both sides must pass. An object whose DACL names only Administrators/SYSTEM plus the',
|
|
865
|
+
' capability SID is refused on the read side as well, so it looks granted to a check that only searches for the',
|
|
866
|
+
' capability SID — #423 measured exactly that on a `.cache` directory. A check that asks only "is the SID there?"',
|
|
867
|
+
' reports those objects as fine while LIST and WRITE are both denied.',
|
|
868
|
+
' A SECOND measured variant has the same shape and a different object: the coverage that is missing can be the',
|
|
869
|
+
' mandatory-integrity LABEL rather than a DACL entry. It was reported in this same thread on 2026-09-29 against a',
|
|
870
|
+
' `0.2.0-rc.1` install, under the same root-only short-circuit. Inside a session the two are indistinguishable;',
|
|
871
|
+
' from outside, the repository\'s own diagnosis skill separates them — `diagnose-windows-sandbox-acl` (0.2.0 and',
|
|
872
|
+
' later) reports `hasExactDeny()` for the DACL half and `LOW_LABEL` (`S-1-16-4096`) for the label half.',
|
|
873
|
+
'',
|
|
874
|
+
'Why the natural repair is closed — this is a Windows ceiling, not a mistake in the command:',
|
|
875
|
+
' The grant the root carries is written for a capability SID, and a recursive `icacls /grant "*S-1-4-…:…" /T /C`',
|
|
876
|
+
' is refused with ERROR_NONE_MAPPED (1332): the tool cannot map that SID to a name, so a grant that needs to name',
|
|
877
|
+
' it never reaches the child. Widening the grant to a nameable account is a different grant with different',
|
|
878
|
+
' consequences, not this one restored.',
|
|
879
|
+
'',
|
|
880
|
+
'What helps:',
|
|
881
|
+
' - The move that is yours, and the only one available inside the session: write new files under a directory the',
|
|
882
|
+
' harness itself created in this workspace. Those carry the grant, because their inheritance did apply — the',
|
|
883
|
+
' reporter\'s own measurement, that objects DSH created are complete here and externally created ones are not.',
|
|
884
|
+
' - The user\'s move: repair the specific object from an account that already holds WRITE_DAC on it, by writing',
|
|
885
|
+
' the ACE the root carries into the child\'s own DACL. That needs the very right that is missing, so it is not',
|
|
886
|
+
' something an unelevated prompt can do.',
|
|
887
|
+
' No repair command is printed here on purpose. This project has no Windows host to verify one on, and shipping',
|
|
888
|
+
' an unverified line would be the same defect this plugin exists to answer. The mechanism above is what is known;',
|
|
889
|
+
' the line is yours to choose.',
|
|
890
|
+
'',
|
|
891
|
+
'The retry you are about to be offered: the denial surface offers one retry of this exact command under a wider',
|
|
892
|
+
'mode. That retry can only succeed by removing the confinement itself — it repairs nothing in the workspace, and',
|
|
893
|
+
'the next session meets the same gap. Take it only if that is what you mean to buy.',
|
|
894
|
+
'',
|
|
895
|
+
'Do NOT retry this call unchanged: the provisioning path short-circuits on the root grant, so the object that was',
|
|
896
|
+
'skipped is never revisited.',
|
|
897
|
+
'',
|
|
898
|
+
'How to read this: the denial names no cause and points at no repair, so the diagnosis is delivered here instead.',
|
|
899
|
+
'This is a stopgap, ' + where + '. This plugin speaks only when the mode is `workspace-write`, the host is',
|
|
900
|
+
'Windows, and every path the command names lies inside the workspace — a denial anywhere else is the sandbox',
|
|
901
|
+
'working as designed, and a confident wrong cause is worse than no answer.',
|
|
902
|
+
'What it is NOT: this plugin does not edit an ACL, does not elevate, and does not offer `danger-full-access` as a',
|
|
903
|
+
'fix.',
|
|
904
|
+
].join('\n');
|
|
905
|
+
}
|
|
716
906
|
/**
|
|
717
907
|
* Build the pre-dispatch denial for the optional fail-fast half.
|
|
718
908
|
*
|
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 four 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
|
|
@@ -68,6 +68,26 @@
|
|
|
68
68
|
* tool's own canonical output, never a line of rendered text — and which three
|
|
69
69
|
* narrowings keep the recognition from firing on something else.
|
|
70
70
|
*
|
|
71
|
+
* **Denied inside the workspace (Windows ACL, `#423`).** The fourth family is
|
|
72
|
+
* the other half of the backend the first one explains, and it is the first
|
|
73
|
+
* whose remedy is withheld on purpose. There the grant could not be applied at
|
|
74
|
+
* all; here it *was* applied — on the workspace root, once — and Windows ACE
|
|
75
|
+
* inheritance silently skipped objects whose DACL the caller could not write.
|
|
76
|
+
* The backend's provisioning check short-circuits on the root (`hasExactGrant`
|
|
77
|
+
* returns early once the root carries the ACE, `sandbox-windows-acl/src/acl.ts`),
|
|
78
|
+
* so those descendants are never revisited and the same command fails forever,
|
|
79
|
+
* while a directory the harness itself created works. It arrives at the same
|
|
80
|
+
* seam as the native-init family and for the same structural reason: a denied
|
|
81
|
+
* command exits nonzero and the shipped shell tools report that as a finished
|
|
82
|
+
* run, so the fact is in `ToolExecutionSuccess.value` rather than in an error.
|
|
83
|
+
* Unlike that family it needs no bespoke code — the executors stamp the denial
|
|
84
|
+
* onto the value as a structured triple (`sandbox: { mode, denied, enforcement? }`,
|
|
85
|
+
* produced by matching the backend's own refusal dialect in the captured
|
|
86
|
+
* stderr), so this plugin reads the harness's own reading of its own sandbox.
|
|
87
|
+
* See `src/signature.ts` for the facts that narrow it, and in particular for
|
|
88
|
+
* why a denial is the *designed* outcome in three other situations (outside the
|
|
89
|
+
* workspace, under `read-only`, a runner failure) and must never be advised.
|
|
90
|
+
*
|
|
71
91
|
* ## Where it acts, and why there
|
|
72
92
|
*
|
|
73
93
|
* One listener on the public `tools/post-execute` waterfall
|
|
@@ -107,9 +127,15 @@
|
|
|
107
127
|
* packaged desktop binary, which the plugin **measures and reports** rather
|
|
108
128
|
* than assumes), and carries the one conversion a model can actually make —
|
|
109
129
|
* rewrite the work as PowerShell or `cmd` when the program that could not
|
|
110
|
-
* start was an MSYS2 one.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
130
|
+
* start was an MSYS2 one. For the workspace-denial family it prints the path
|
|
131
|
+
* it keyed on beside the root it tested it against, says that retrying is
|
|
132
|
+
* provably useless (the root-only provisioning check never revisits the
|
|
133
|
+
* object), gives the two-sided reachability rule, names the label variant of
|
|
134
|
+
* the same shape, and prints **no** repair command — the obvious one is
|
|
135
|
+
* refused by Windows with `ERROR_NONE_MAPPED (1332)`, and this project has no
|
|
136
|
+
* Windows host to verify a line on. All four ride `additionalContexts`, so the
|
|
137
|
+
* model sees the diagnosis beside the failure rather than only in a log it
|
|
138
|
+
* never reads.
|
|
113
139
|
* 2. **A bounded fail-fast, ACL family only.** With `enforceAfter` set, a call
|
|
114
140
|
* this plugin has *watched fail* this way is refused at `tools/pre-execute`
|
|
115
141
|
* once the environment has failed at least that many times. It is off by
|
|
@@ -121,8 +147,13 @@
|
|
|
121
147
|
* only sent when the resolved mode actually confines; if the mode is not
|
|
122
148
|
* confining, or cannot be
|
|
123
149
|
* resolved at all, the failure is left exactly as it was **and the host log
|
|
124
|
-
* says so once**.
|
|
125
|
-
*
|
|
150
|
+
* says so once**. The workspace-denial advisory is withheld the same way when
|
|
151
|
+
* the workspace root — the fact the containment claim is tested against —
|
|
152
|
+
* cannot be resolved. Silence alone would make "the sandbox is not the cause" and
|
|
153
|
+
* "this plugin could not tell" indistinguishable from the outside. A denial
|
|
154
|
+
* the classifier *can* place outside the workspace is a different case and is
|
|
155
|
+
* left silent on purpose: that is the sanctioned escalation path, not a
|
|
156
|
+
* puzzle, and a note about it would be noise.
|
|
126
157
|
*
|
|
127
158
|
* ## Honest boundaries
|
|
128
159
|
*
|
|
@@ -139,7 +170,10 @@
|
|
|
139
170
|
* tools' own foreground projection with the reported codes, including the
|
|
140
171
|
* signed form the reporter saw (`-1073741502`) and the real MSYS2 stderr, so the
|
|
141
172
|
* recognition runs against the producer's data rather than against a message
|
|
142
|
-
* this plugin invented.
|
|
173
|
+
* this plugin invented. The workspace-denial family is exercised the same way:
|
|
174
|
+
* the test builds the executors' own `sandbox` stamp and the shipped foreground
|
|
175
|
+
* projection, supplies `win32` as the platform fact, and covers both the
|
|
176
|
+
* recognized case and each of the narrowings that must stay silent.
|
|
143
177
|
* - **It does not repair anything.** No ACL is written, no privilege is
|
|
144
178
|
* requested, nothing is elevated, no environment variable is set for another
|
|
145
179
|
* process, no preset is installed and no mode is changed: the remedies are the
|
|
@@ -148,7 +182,7 @@
|
|
|
148
182
|
* guard keys on *call identity* (identical arguments retried); this one keys
|
|
149
183
|
* on the *environment signature*, which is how several different commands can
|
|
150
184
|
* share one cause. They can be mounted together.
|
|
151
|
-
* - **The real fix is upstream**, in all
|
|
185
|
+
* - **The real fix is upstream**, in all four families: the ACL failure should
|
|
152
186
|
* name
|
|
153
187
|
* the outstanding condition at the site that knows it (`grantWrite` computes
|
|
154
188
|
* `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
|
|
@@ -156,14 +190,19 @@
|
|
|
156
190
|
* incompatible with the PTY backend" or fall back to a one-shot shell, and the
|
|
157
191
|
* sandbox runner should be launched with the environment its own execution
|
|
158
192
|
* needs (`ELECTRON_RUN_AS_NODE` when argv[0] is an Electron binary) or with a
|
|
159
|
-
* documented, checkable refusal for MSYS2 programs.
|
|
193
|
+
* documented, checkable refusal for MSYS2 programs. The workspace-denial family
|
|
194
|
+
* is the same shape once more: `grantWrite` returns early when the *root*
|
|
195
|
+
* already carries the ACE, so the descendants that missed the propagation are
|
|
196
|
+
* never repaired — the check would have to look past the root, or the denial
|
|
197
|
+
* surface would have to say *which* path was refused instead of only that one
|
|
198
|
+
* was. This plugin is the stopgap.
|
|
160
199
|
*
|
|
161
200
|
* @module @argszero/cordis-plugin-sandbox-grant-advisor
|
|
162
201
|
*/
|
|
163
202
|
import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
164
|
-
import { advisoryText, ACL_DISCUSSIONS, denialText, NATIVE_INIT_DISCUSSIONS, PTY_DISCUSSIONS } from './advice.js';
|
|
165
|
-
import { classifyNativeInitDeath, classifyProvisioningFailure, classifyPtyStartupFailure } from './signature.js';
|
|
166
|
-
import { confines, resolveSandboxMode } from './mode.js';
|
|
203
|
+
import { advisoryText, ACL_DISCUSSIONS, denialText, DISCUSSIONS_OF, NATIVE_INIT_DISCUSSIONS, PTY_DISCUSSIONS, WORKSPACE_DENIAL_DISCUSSIONS } from './advice.js';
|
|
204
|
+
import { classifyNativeInitDeath, classifyProvisioningFailure, classifyPtyStartupFailure, classifyWorkspaceDenial, hasWorkspaceDenialStamp, } from './signature.js';
|
|
205
|
+
import { confines, resolveSandboxMode, resolveWorkspaceRoot } from './mode.js';
|
|
167
206
|
import { advisedOf, callKey, observe, observeSuccess, recordAdvice, recordDenial, recordWithheld, shouldDeny, } from './state.js';
|
|
168
207
|
export const name = 'sandbox-grant-advisor';
|
|
169
208
|
/** The tool pipeline this plugin observes and (optionally) gates. */
|
|
@@ -303,6 +342,10 @@ function summaryOf(failure, mode) {
|
|
|
303
342
|
return `sandboxed command never started (exit ${String(failure.rawExitCode)}, STATUS_DLL_INIT_FAILED) `
|
|
304
343
|
+ `under sandbox mode "${String(mode)}"`;
|
|
305
344
|
}
|
|
345
|
+
if (failure.family === 'workspace-denial') {
|
|
346
|
+
const subject = failure.paths[0] ?? 'a path in the workspace';
|
|
347
|
+
return `denied inside the workspace (${subject}) under sandbox mode "${failure.mode}"`;
|
|
348
|
+
}
|
|
306
349
|
return `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`;
|
|
307
350
|
}
|
|
308
351
|
/**
|
|
@@ -346,16 +389,16 @@ export function apply(ctx, config = {}) {
|
|
|
346
389
|
* @param agent - the agent whose failure was withheld.
|
|
347
390
|
* @param state - the agent's state, to keep the note to one.
|
|
348
391
|
* @param why - what stopped the advisory.
|
|
349
|
-
* @param
|
|
350
|
-
*
|
|
392
|
+
* @param family - the recognized family that was withheld. Only families whose
|
|
393
|
+
* gate can fail closed reach this function, so the thread it cites is exact.
|
|
351
394
|
* @returns undefined, so callers can `return withhold(...)`.
|
|
352
395
|
*/
|
|
353
|
-
function withhold(agent, state, why,
|
|
396
|
+
function withhold(agent, state, why, family) {
|
|
354
397
|
if (state?.withheld === true)
|
|
355
398
|
return undefined;
|
|
356
399
|
states.set(agent, recordWithheld(state));
|
|
357
|
-
const discussions =
|
|
358
|
-
ctx.logger.warn(`sandbox-grant-advisor: ${
|
|
400
|
+
const discussions = DISCUSSIONS_OF[family];
|
|
401
|
+
ctx.logger.warn(`sandbox-grant-advisor: ${family} failure recognized but no advisory sent — ${why}; the raw `
|
|
359
402
|
+ `error is left exactly as it is, so this is NOT a claim that the sandbox is unrelated (discussions ${discussions})`);
|
|
360
403
|
return undefined;
|
|
361
404
|
}
|
|
@@ -383,12 +426,20 @@ export function apply(ctx, config = {}) {
|
|
|
383
426
|
// read from `result.value` and never from the rendered text, so a command
|
|
384
427
|
// whose own output mentions the code cannot be mistaken for it.
|
|
385
428
|
const death = classifyNativeInitDeath(result.value);
|
|
386
|
-
if (death
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
429
|
+
if (death !== undefined)
|
|
430
|
+
return adviseGated(agent, previous, death, key, exec.name);
|
|
431
|
+
// The workspace-denial family is the other value-read one, and it is read
|
|
432
|
+
// only when the host is Windows: the mechanism it explains is ACE
|
|
433
|
+
// inheritance, which no other backend has, so on macOS/Linux the same
|
|
434
|
+
// stamp is a different story and is left alone with no note at all (that
|
|
435
|
+
// is the designed denial path there, not a puzzle). The platform test
|
|
436
|
+
// comes first so a non-Windows host never pays for the policy lookup.
|
|
437
|
+
if (process.platform === 'win32' && hasWorkspaceDenialStamp(result.value)) {
|
|
438
|
+
return adviseWorkspaceDenial(agent, previous, result.value, exec.arguments, key, exec.name);
|
|
390
439
|
}
|
|
391
|
-
|
|
440
|
+
if (previous !== undefined)
|
|
441
|
+
states.set(agent, observeSuccess(previous, key));
|
|
442
|
+
return undefined;
|
|
392
443
|
}
|
|
393
444
|
// The two text families read different fields, on purpose. The ACL signature
|
|
394
445
|
// carries an API name plus a Win32 code, which a command's own output does
|
|
@@ -441,12 +492,12 @@ export function apply(ctx, config = {}) {
|
|
|
441
492
|
function adviseGated(agent, previous, failure, key, tool) {
|
|
442
493
|
const resolution = resolveSandboxMode(ctx, agent);
|
|
443
494
|
if (!resolution.ok)
|
|
444
|
-
return withhold(agent, previous, resolution.withheld, failure);
|
|
495
|
+
return withhold(agent, previous, resolution.withheld, failure.family);
|
|
445
496
|
const mode = resolution.mode;
|
|
446
497
|
if (!confines(mode)) {
|
|
447
498
|
const what = failure.family === 'pty-startup' ? 'the shell' : 'the command';
|
|
448
499
|
return withhold(agent, previous, `the failing call ran under \`${mode}\`, where ${what} is not spawned `
|
|
449
|
-
+ 'through the sandbox', failure);
|
|
500
|
+
+ 'through the sandbox', failure.family);
|
|
450
501
|
}
|
|
451
502
|
if (!claimAdvice(agent, previous, failure, key))
|
|
452
503
|
return undefined;
|
|
@@ -468,6 +519,59 @@ export function apply(ctx, config = {}) {
|
|
|
468
519
|
states.set(agent, first ? recordAdvice(advanced, failure.family) : advanced);
|
|
469
520
|
return first;
|
|
470
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Diagnose one denial whose target lies **inside** the agent's own workspace.
|
|
524
|
+
*
|
|
525
|
+
* The fourth family reads the executor's structured stamp rather than a
|
|
526
|
+
* message, so what is left here is the one fact the stamp cannot carry: the
|
|
527
|
+
* workspace root, which the containment claim has to be tested against. It is
|
|
528
|
+
* resolved from the agent's own session (the same resolver the enforcing
|
|
529
|
+
* providers are handed), and a root that cannot be resolved withholds the
|
|
530
|
+
* advisory rather than guessing — but only after the stamp has been seen, so
|
|
531
|
+
* an ordinary successful call costs no note. Once the root is known the
|
|
532
|
+
* classifier decides; a value that is a denial but names no in-workspace path
|
|
533
|
+
* is the designed escalation path and is left silent on purpose, which is why
|
|
534
|
+
* the `undefined` from the classifier is *not* routed through `withhold`.
|
|
535
|
+
* @param agent - the agent whose call was denied.
|
|
536
|
+
* @param previous - the agent's state before this call, if any.
|
|
537
|
+
* @param value - the settled call's canonical value.
|
|
538
|
+
* @param args - the settled call's parsed arguments.
|
|
539
|
+
* @param key - the identity of the denied call.
|
|
540
|
+
* @param tool - the denied tool's name, for the advisory context.
|
|
541
|
+
* @returns the notice to attach, or undefined.
|
|
542
|
+
*/
|
|
543
|
+
function adviseWorkspaceDenial(agent, previous, value, args, key, tool) {
|
|
544
|
+
const resolution = resolveWorkspaceRoot(ctx, agent);
|
|
545
|
+
if (!resolution.ok)
|
|
546
|
+
return withhold(agent, previous, resolution.withheld, 'workspace-denial');
|
|
547
|
+
const failure = classifyWorkspaceDenial(value, args, {
|
|
548
|
+
platform: process.platform,
|
|
549
|
+
workspaceRoot: resolution.workspaceRoot,
|
|
550
|
+
});
|
|
551
|
+
if (failure === undefined)
|
|
552
|
+
return undefined;
|
|
553
|
+
if (!claimAdvice(agent, previous, failure, key))
|
|
554
|
+
return undefined;
|
|
555
|
+
ctx.logger.warn(workspaceDenialHostLine(failure));
|
|
556
|
+
return notice(advisoryText(failure, advisoryContext(tool)), summaryOf(failure));
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* The one-line host-side account of a recognized workspace-internal denial.
|
|
560
|
+
*
|
|
561
|
+
* It carries the path the plugin keyed on, because this family's whole claim
|
|
562
|
+
* is that one named path lies inside one named root — a maintainer reading the
|
|
563
|
+
* log is entitled to see both halves of the string comparison rather than a
|
|
564
|
+
* verdict about it.
|
|
565
|
+
* @param failure - the recognized failure.
|
|
566
|
+
* @returns a single log line.
|
|
567
|
+
*/
|
|
568
|
+
function workspaceDenialHostLine(failure) {
|
|
569
|
+
const subject = failure.paths[0] ?? '<no path recovered>';
|
|
570
|
+
const more = failure.paths.length > 1 ? ` (+${String(failure.paths.length - 1)} more inside the same root)` : '';
|
|
571
|
+
return `sandbox-grant-advisor: confined command denied ${subject}${more}, which is INSIDE the workspace `
|
|
572
|
+
+ `${failure.workspaceRoot}, under sandbox mode "${failure.mode}" — the root-only grant skipped this object and `
|
|
573
|
+
+ `is never revisited, so retrying cannot help; advisory delivered to the model (discussion ${WORKSPACE_DENIAL_DISCUSSIONS})`;
|
|
574
|
+
}
|
|
471
575
|
/**
|
|
472
576
|
* The advisory context for one failing call.
|
|
473
577
|
*
|
package/lib/mode.js
CHANGED
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* session — so what is quoted in the advisory is the policy that actually
|
|
20
20
|
* governed the failing call, not a guess reconstructed from configuration.
|
|
21
21
|
*
|
|
22
|
+
* The same lookup answers the **workspace root** for the fourth family
|
|
23
|
+
* (`workspace-denial`, #423), whose claim is that the path a denied command
|
|
24
|
+
* named lies inside the session's own workspace. Same request, same fail-closed
|
|
25
|
+
* posture, separate function ({@link resolveWorkspaceRoot}), so that neither
|
|
26
|
+
* family can fail on a field it never reads and each can name the failure in its
|
|
27
|
+
* own words.
|
|
28
|
+
*
|
|
22
29
|
* ## Why this is a guarded lookup instead of an import
|
|
23
30
|
*
|
|
24
31
|
* `@deepseek-ai/dsh-sandbox-policy` is **optional** in this plugin's world: a
|
|
@@ -106,10 +113,60 @@ function recognizedMode(resolved) {
|
|
|
106
113
|
* @returns the mode, or the reason it could not be resolved.
|
|
107
114
|
*/
|
|
108
115
|
export function resolveSandboxMode(ctx, agent) {
|
|
116
|
+
const lookup = policyOf(ctx, agent);
|
|
117
|
+
if (!lookup.ok)
|
|
118
|
+
return { ok: false, withheld: lookup.withheld };
|
|
119
|
+
const mode = recognizedMode(lookup.resolved);
|
|
120
|
+
if (mode === undefined) {
|
|
121
|
+
return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a recognizable mode' };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, mode };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The workspace root the policy resolver reports for one agent's call.
|
|
127
|
+
*
|
|
128
|
+
* The same lookup, the same request and the same fail-closed posture as
|
|
129
|
+
* {@link resolveSandboxMode}, and for the same reason: the root is what the
|
|
130
|
+
* enforcing providers were handed, so it is the value the workspace-denial
|
|
131
|
+
* family must test containment against rather than one reconstructed from
|
|
132
|
+
* configuration. The two are separate functions rather than one that returns
|
|
133
|
+
* both because each family needs one of them, and a family that needs only the
|
|
134
|
+
* mode must not be able to fail on a missing root (nor the other way round).
|
|
135
|
+
* @param ctx - the plugin's context.
|
|
136
|
+
* @param agent - the agent whose call failed.
|
|
137
|
+
* @returns the root, or the reason it could not be resolved.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveWorkspaceRoot(ctx, agent) {
|
|
140
|
+
const lookup = policyOf(ctx, agent);
|
|
141
|
+
if (!lookup.ok) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
withheld: lookup.why === 'unmounted'
|
|
145
|
+
? 'no `sandboxPolicy` service is mounted in this composition, so the workspace root is unknown'
|
|
146
|
+
: lookup.withheld,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const resolved = lookup.resolved;
|
|
150
|
+
const root = resolved === null || typeof resolved !== 'object'
|
|
151
|
+
? undefined
|
|
152
|
+
: resolved.workspaceRoot;
|
|
153
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
154
|
+
return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a usable workspace root' };
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, workspaceRoot: root };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Ask the mounted policy service for one agent's resolved policy.
|
|
160
|
+
* @param ctx - the plugin's context.
|
|
161
|
+
* @param agent - the agent whose call is being placed.
|
|
162
|
+
* @returns the resolver's answer, or the reason there is none.
|
|
163
|
+
*/
|
|
164
|
+
function policyOf(ctx, agent) {
|
|
109
165
|
const policy = asPolicy(ctx.get('sandboxPolicy'));
|
|
110
166
|
if (policy === undefined) {
|
|
111
167
|
return {
|
|
112
168
|
ok: false,
|
|
169
|
+
why: 'unmounted',
|
|
113
170
|
withheld: 'no `sandboxPolicy` service is mounted in this composition, so the effective mode is unknown',
|
|
114
171
|
};
|
|
115
172
|
}
|
|
@@ -117,19 +174,14 @@ export function resolveSandboxMode(ctx, agent) {
|
|
|
117
174
|
if (session === undefined) {
|
|
118
175
|
return {
|
|
119
176
|
ok: false,
|
|
177
|
+
why: 'no-session',
|
|
120
178
|
withheld: 'the agent exposes no session, and the policy must be resolved from it rather than from the deployment default',
|
|
121
179
|
};
|
|
122
180
|
}
|
|
123
|
-
let resolved;
|
|
124
181
|
try {
|
|
125
|
-
resolved
|
|
182
|
+
return { ok: true, resolved: policy.resolve({ session }) };
|
|
126
183
|
}
|
|
127
184
|
catch (error) {
|
|
128
|
-
return { ok: false, withheld: `\`sandboxPolicy.resolve\` threw (${String(error)})` };
|
|
129
|
-
}
|
|
130
|
-
const mode = recognizedMode(resolved);
|
|
131
|
-
if (mode === undefined) {
|
|
132
|
-
return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a recognizable mode' };
|
|
185
|
+
return { ok: false, why: 'threw', withheld: `\`sandboxPolicy.resolve\` threw (${String(error)})` };
|
|
133
186
|
}
|
|
134
|
-
return { ok: true, mode };
|
|
135
187
|
}
|