@argszero/cordis-plugin-sandbox-grant-advisor 0.1.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/LICENSE +21 -0
- package/README.md +212 -0
- package/cordis.patch.yml +51 -0
- package/lib/advice.js +116 -0
- package/lib/index.js +285 -0
- package/lib/signature.js +81 -0
- package/lib/state.js +145 -0
- package/lib/types/advice.d.ts +42 -0
- package/lib/types/index.d.ts +115 -0
- package/lib/types/signature.d.ts +66 -0
- package/lib/types/state.d.ts +110 -0
- package/package.json +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 argszero
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# @argszero/cordis-plugin-sandbox-grant-advisor
|
|
2
|
+
|
|
3
|
+
Turns a Windows sandbox **ACL provisioning failure with no path forward** into a
|
|
4
|
+
diagnosis the model — and the user reading the transcript — can act on.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Three reports describe this exact line: [discussion #7538], [discussion #7622],
|
|
11
|
+
[discussion #7646]. In each one every sandboxed command fails the same way,
|
|
12
|
+
before it runs, and the error names neither the missing right nor a remedy.
|
|
13
|
+
|
|
14
|
+
**This plugin is the stopgap for "the error does not name the outstanding
|
|
15
|
+
condition".** It does not repair anything: no ACL is written, no privilege is
|
|
16
|
+
requested, nothing is elevated.
|
|
17
|
+
|
|
18
|
+
## The failure it recognizes
|
|
19
|
+
|
|
20
|
+
The Windows backend provisions a workspace by writing the directory's DACL and
|
|
21
|
+
its mandatory-integrity label in **one** `SetNamedSecurityInfoW` call
|
|
22
|
+
(`packages/sandbox/sandbox-windows-acl/src/acl.ts`:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
). Two consequences follow from that one line:
|
|
29
|
+
|
|
30
|
+
1. **The label lives in the SACL, and its half is what gets refused.** The
|
|
31
|
+
owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`, so the
|
|
32
|
+
combined apply additionally needs **`WRITE_OWNER` on the directory** — an
|
|
33
|
+
*object right*, which a Full-control directory (the normal workspace case)
|
|
34
|
+
has and a `mkdir`-created one inheriting "Authenticated Users: Modify"
|
|
35
|
+
(`0x1301bf`) does not. This is the backend's own documented prerequisite
|
|
36
|
+
("granted directories must be caller-owned and grant `WRITE_OWNER`").
|
|
37
|
+
2. **It is not `SeSecurityPrivilege`, and elevation is the wrong lever.** That
|
|
38
|
+
is the token-privilege form of the same idea, and it is the hypothesis the
|
|
39
|
+
reports naturally reach for — `whoami /priv` cannot tell the two apart,
|
|
40
|
+
because `WRITE_OWNER` is an object right and never appears in that table.
|
|
41
|
+
Granting Full control to the workspace root needs **no** elevation.
|
|
42
|
+
|
|
43
|
+
The grant is materialized lazily, on the first confined call, and **nothing is
|
|
44
|
+
cached when it throws** — so the same failure repeats per command (850 calls
|
|
45
|
+
across 39 sessions in #7622; 52,588 output tokens with no output in #7538),
|
|
46
|
+
which is why the loop cannot separate it from ordinary command noise.
|
|
47
|
+
|
|
48
|
+
## What it does
|
|
49
|
+
|
|
50
|
+
One listener on the public **`tools/post-execute`** waterfall
|
|
51
|
+
(`@deepseek-ai/dsh-tools`). That seam — not `ctx.sandbox.confine` — because it is
|
|
52
|
+
the only one that has all three of: the failure text (providers propagate their
|
|
53
|
+
error unchanged, and the tool pipeline settles it as an `isError` result), an
|
|
54
|
+
agent identity to attribute it to (`exec.agent`), and a channel that speaks to
|
|
55
|
+
the model in the same step (`PostToolDecision`'s `additionalContexts`, which the
|
|
56
|
+
agent loop turns into a durable user-role message —
|
|
57
|
+
`packages/core/agent-loop/src/tool-calls.ts`).
|
|
58
|
+
|
|
59
|
+
1. **One durable advisory per agent.** On the first recognized failure, the
|
|
60
|
+
result is enriched with a user-role notice that names the missing right, the
|
|
61
|
+
discriminator, and the unelevated fix:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.
|
|
65
|
+
|
|
66
|
+
What was reported:
|
|
67
|
+
SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
|
|
68
|
+
|
|
69
|
+
Why it is refused while the directory looks writable: that call is a MERGED write ...
|
|
70
|
+
... the label half additionally needs WRITE_OWNER on the directory. ...
|
|
71
|
+
|
|
72
|
+
Confirm the cause (unelevated) — `icacls` is a normal user command:
|
|
73
|
+
icacls "D:\ws"
|
|
74
|
+
|
|
75
|
+
Fix it (unelevated, one line) and then run the command again:
|
|
76
|
+
PowerShell: icacls "D:\ws" /grant "$env:USERNAME:(OI)(CI)F"
|
|
77
|
+
cmd: icacls "D:\ws" /grant "%USERNAME%:(OI)(CI)F"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The notice carries its own producer-owned `source.kind`
|
|
81
|
+
(`sandbox-grant-advisor`) — not the retired `plugin` wrapper, which the
|
|
82
|
+
current session format refuses — and a bounded one-line `summary` for the
|
|
83
|
+
transcript row. The host log gets one matching `warn` line, so the fact
|
|
84
|
+
survives outside the transcript too.
|
|
85
|
+
2. **An optional, bounded fail-fast half** (`enforceAfter`, default **0** =
|
|
86
|
+
off). It refuses a call **before dispatch** only when both hold: the
|
|
87
|
+
environment has failed provisioning at least `enforceAfter` times, **and**
|
|
88
|
+
this exact call (tool + canonical arguments) is one this plugin watched fail.
|
|
89
|
+
The budget is `maxDenials` (default 2), after which the call proceeds again.
|
|
90
|
+
The budget is per **episode of brokenness**: a call that finally succeeds stops
|
|
91
|
+
being a denial target and re-arms it, so an environment that breaks twice can
|
|
92
|
+
be refused twice — while a session can always make progress by spending the
|
|
93
|
+
budget it has.
|
|
94
|
+
|
|
95
|
+
## Install
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
npm install @argszero/cordis-plugin-sandbox-grant-advisor
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Mount it by adding the patch to your profile, or apply the shipped
|
|
102
|
+
`cordis.patch.yml`:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
- insert:
|
|
106
|
+
- id: sandbox-grant-advisor
|
|
107
|
+
name: '@argszero/cordis-plugin-sandbox-grant-advisor'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
| option | default | meaning |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `enforceAfter` | `0` | Provisioning failures in one agent after which an identical, already-failing call is refused before dispatch. `0` disables the blocking half entirely. |
|
|
115
|
+
| `maxDenials` | `2` | Denials one agent may spend per episode of brokenness. Bounded on purpose; re-armed when a watched call finally succeeds. |
|
|
116
|
+
| `include` | `[]` | Tool-name wildcard patterns to watch; empty means every tool. |
|
|
117
|
+
| `exclude` | `[]` | Tool-name wildcard patterns never watched. |
|
|
118
|
+
| `href` | — | URL quoted in the advisory as the upstream thread, instead of the discussion numbers. |
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
- set:
|
|
122
|
+
- id: sandbox-grant-advisor
|
|
123
|
+
config:
|
|
124
|
+
enforceAfter: 3
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## What it deliberately refuses to explain
|
|
128
|
+
|
|
129
|
+
Recognition is narrow, because a classifier that names the wrong cause is worse
|
|
130
|
+
than one that stays silent.
|
|
131
|
+
|
|
132
|
+
- **Only the two `...NamedSecurityInfoW` operations are classified.**
|
|
133
|
+
`SetEntriesInAclW` merges access entries in process memory — there is no
|
|
134
|
+
object and no rights involved — so its failure is *not* an ACL-permission
|
|
135
|
+
problem, and neither are the `LocalFree`, `LockFileEx`,
|
|
136
|
+
`SetConsoleCtrlHandler` or `SetEnvironmentVariableW` failures thrown by the
|
|
137
|
+
same package.
|
|
138
|
+
- **The Win32 code is kept, not flattened.** `ERROR_ACCESS_DENIED` (5) is the
|
|
139
|
+
case the documented prerequisite explains; another code gets a different
|
|
140
|
+
paragraph that says so instead of borrowing the same sentence.
|
|
141
|
+
- **A successful command whose *output* contains the line is not a failure.**
|
|
142
|
+
The gate is the result's error state, not the presence of the text — reading a
|
|
143
|
+
log file that quotes the error must not trigger advice.
|
|
144
|
+
- **Only one advisory per agent.** The environment is explained once; repeating
|
|
145
|
+
it per failed command would be noise competing with the failure itself.
|
|
146
|
+
|
|
147
|
+
## Honest boundaries
|
|
148
|
+
|
|
149
|
+
- **The Windows path itself cannot be witnessed on macOS**, where this plugin was
|
|
150
|
+
built. What the test suite proves is the decision layer — classification, the
|
|
151
|
+
once-per-agent rule, the fail-fast budget and its self-feeding guard, and the
|
|
152
|
+
wiring to a real cordis `Context` and the real `ToolRuntime` — driven by
|
|
153
|
+
fixtures that throw the producer's exact error shape (`Win32Error`,
|
|
154
|
+
`packages/subprocess/win32-process/src/errors.ts`). It does **not** prove that
|
|
155
|
+
`icacls ... :(OI)(CI)F` fixes a given machine; that is the user's one-line
|
|
156
|
+
experiment, and the advisory says so.
|
|
157
|
+
- **It repairs nothing and elevates nothing.** If the directory really is
|
|
158
|
+
Full-control for the caller, the remaining hypothesis is `SeSecurityPrivilege`
|
|
159
|
+
— i.e. the backend's documented prerequisite would be wrong. That is an
|
|
160
|
+
upstream question; the advisory states the discriminator rather than assuming
|
|
161
|
+
the answer.
|
|
162
|
+
- **Delivery to the model is the agent loop's.** `additionalContexts` are ferried
|
|
163
|
+
on the settled result here and appended as durable user-role events by
|
|
164
|
+
`agent-loop`; a direct `ctx.tools.execute()` caller with no agent gets no
|
|
165
|
+
advisory (and no agent to explain anything to).
|
|
166
|
+
- **It complements `@argszero/cordis-plugin-repeat-guard-escalation`, it does not
|
|
167
|
+
replace it.** That guard keys on **call identity** (identical arguments
|
|
168
|
+
retried); this one keys on the **environment signature**, which is how several
|
|
169
|
+
*different* commands share one cause. Mounting both is sensible.
|
|
170
|
+
- **The real fix is upstream.** `grantWrite` already computes
|
|
171
|
+
`hasExactGrant` / `hasExactDeny` / `hasExactLabel` and discards which one was
|
|
172
|
+
false, so the diagnostic that turns a 52-minute detour into one line belongs at
|
|
173
|
+
that site — next to the preflight the grant's lazy materialization wants.
|
|
174
|
+
|
|
175
|
+
## Compatibility
|
|
176
|
+
|
|
177
|
+
Harness peers — all three carry the **same** range, quoted in full on purpose
|
|
178
|
+
(a partially quoted range admits fewer lines than the published one):
|
|
179
|
+
|
|
180
|
+
- `@deepseek-ai/dsh-llm@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0` — the only **runtime**
|
|
181
|
+
import: `createUserMessage` and `boundContextSummary`.
|
|
182
|
+
- `@deepseek-ai/dsh-agent@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0` and
|
|
183
|
+
`@deepseek-ai/dsh-tools@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0` — imported for their **types** only (`Agent`,
|
|
184
|
+
`ToolExecution`, `PostToolDecision`, …). Nothing is loaded from them at
|
|
185
|
+
runtime, but the shipped `lib/types/index.d.ts` still names them, so a consumer
|
|
186
|
+
on a line outside this range fails to typecheck against this package's own
|
|
187
|
+
declarations; that is a compatibility claim, and it is declared as a peer for
|
|
188
|
+
that reason.
|
|
189
|
+
- `@deepseek-ai/cordis@^4.0.2`.
|
|
190
|
+
|
|
191
|
+
Probed at the newest build of every line the range admits — `0.1.2-rc.1`,
|
|
192
|
+
`0.1.3-alpha.2`, `0.1.5-rc.3`, `0.1.6-alpha.2`, `0.1.7-rc.1` (the build the third
|
|
193
|
+
report ran) — with `npm run test:probe-lines`, which derives those builds from
|
|
194
|
+
this range, installs each one from the registry into a scratch tree and runs the
|
|
195
|
+
suite against it. A line whose probe fails is removed from the range rather than
|
|
196
|
+
left claimed.
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
npm install
|
|
202
|
+
npm test # tsc, then the suite (real cordis + real ToolRuntime)
|
|
203
|
+
npm run test:probe-lines # install the newest build of each admitted line and run the suite against it
|
|
204
|
+
npm run test:probe-lines -- 0.1.7-rc.1 # one line only
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The suite is mostly control arms: a guard that explains the wrong failure, or
|
|
208
|
+
refuses a call that would have worked, is worse than one that stays silent.
|
|
209
|
+
|
|
210
|
+
[discussion #7538]: https://github.com/deepseek-ai/deepseek-harness/discussions/7538
|
|
211
|
+
[discussion #7622]: https://github.com/deepseek-ai/deepseek-harness/discussions/7622
|
|
212
|
+
[discussion #7646]: https://github.com/deepseek-ai/deepseek-harness/discussions/7646
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# The @argszero/cordis-plugin-sandbox-grant-advisor bundle patch: mount and go.
|
|
2
|
+
#
|
|
3
|
+
# Three reports (#7538, #7622, #7646) describe one Windows failure with no way
|
|
4
|
+
# forward: the host-side write grant for a sandboxed workspace cannot be
|
|
5
|
+
# applied, so every sandboxed command fails before it runs with
|
|
6
|
+
#
|
|
7
|
+
# SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace>)
|
|
8
|
+
#
|
|
9
|
+
# The grant is materialized lazily on the first confined call and caches
|
|
10
|
+
# nothing when it throws, so the failure repeats per command (850 calls across
|
|
11
|
+
# 39 sessions in #7622; 52,588 output tokens with no output in #7538). The
|
|
12
|
+
# error names neither the missing right nor a remedy, which is why sessions
|
|
13
|
+
# escape into danger-full-access or die on the model's output cap.
|
|
14
|
+
#
|
|
15
|
+
# This plugin does not repair ACLs. It observes the public
|
|
16
|
+
# `tools/post-execute` waterfall, recognizes that signature (and only that
|
|
17
|
+
# signature — see the README for what it deliberately refuses to explain), and
|
|
18
|
+
# attaches ONE durable user-role advisory per agent through
|
|
19
|
+
# `additionalContexts`, so the model gets the diagnosis in the same step as the
|
|
20
|
+
# failure:
|
|
21
|
+
#
|
|
22
|
+
# - the missing right is WRITE_OWNER on the directory — an object right the
|
|
23
|
+
# caller can grant itself, not SeSecurityPrivilege and not elevation
|
|
24
|
+
# (the manifest half of the merged DACL+SACL write is what needs it);
|
|
25
|
+
# - the directory's own ACL is the discriminator (`icacls <dir>`, looking for
|
|
26
|
+
# an ACE that names your SID with (F));
|
|
27
|
+
# - the remedy is one unelevated line:
|
|
28
|
+
# icacls "<dir>" /grant "$env:USERNAME:(OI)(CI)F"
|
|
29
|
+
#
|
|
30
|
+
# Optional config:
|
|
31
|
+
#
|
|
32
|
+
# - set:
|
|
33
|
+
# - id: sandbox-grant-advisor
|
|
34
|
+
# config:
|
|
35
|
+
# # 0 (default) = advisory only: this plugin never blocks a call.
|
|
36
|
+
# # Set it to enable the bounded fail-fast half: once the environment
|
|
37
|
+
# # has failed this way N times, an identical call this plugin has
|
|
38
|
+
# # WATCHED fail is refused before dispatch.
|
|
39
|
+
# enforceAfter: 0
|
|
40
|
+
# maxDenials: 2 # then step aside, so the session can finish
|
|
41
|
+
# exclude: [] # tool-name wildcards never watched
|
|
42
|
+
# href: '' # optional URL quoted in the advisory
|
|
43
|
+
#
|
|
44
|
+
# The advisory half is always on and never blocks anything. The blocking half
|
|
45
|
+
# is off by default on purpose: it may only refuse a call it has watched fail in
|
|
46
|
+
# this environment, and it is bounded by `maxDenials`, because a plugin that can
|
|
47
|
+
# stop command execution must never be the reason a session cannot finish.
|
|
48
|
+
|
|
49
|
+
- insert:
|
|
50
|
+
- id: sandbox-grant-advisor
|
|
51
|
+
name: '@argszero/cordis-plugin-sandbox-grant-advisor'
|
package/lib/advice.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the model — and through it the user — is told about a provisioning
|
|
3
|
+
* failure, and what is deliberately withheld.
|
|
4
|
+
*
|
|
5
|
+
* The text is assembled here as pure functions so every sentence can be pinned
|
|
6
|
+
* by a test. Two properties matter more than the wording:
|
|
7
|
+
*
|
|
8
|
+
* - **It names the right the caller is missing.** The reported failures are
|
|
9
|
+
* `ERROR_ACCESS_DENIED` from a *merged* DACL + SACL write. The missing right
|
|
10
|
+
* is `WRITE_OWNER` on the directory — an object right the caller can grant
|
|
11
|
+
* itself with `icacls`, unelevated. It is **not** `SeSecurityPrivilege`, the
|
|
12
|
+
* token privilege the reports naturally reach for; `whoami /priv` cannot show
|
|
13
|
+
* the difference, and elevation is the wrong lever.
|
|
14
|
+
* - **It gives a discriminator, not just a remedy.** Applying a fix without
|
|
15
|
+
* confirming the cause teaches nothing when the fix does not work. The
|
|
16
|
+
* one-line check (`icacls <dir>`, looking for an ACE that names the caller's
|
|
17
|
+
* own SID and grants `(F)`) separates "Modify-only directory" from "the
|
|
18
|
+
* documented prerequisite is wrong", which is the open question upstream.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
import { failureLine } from './signature.js';
|
|
23
|
+
/** The upstream threads this advisory is a stopgap for. */
|
|
24
|
+
export const DISCUSSIONS = '#7538 / #7622 / #7646';
|
|
25
|
+
/** The documented prerequisite, quoted from the backend's README. */
|
|
26
|
+
export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
|
|
27
|
+
/** Placeholder the user replaces with the directory the error named. */
|
|
28
|
+
const PLACEHOLDER = '<the directory from the error line above>';
|
|
29
|
+
/**
|
|
30
|
+
* The diagnosis paragraph for one class of failure.
|
|
31
|
+
* @param failure - the recognized failure.
|
|
32
|
+
* @returns one paragraph, honest about what is and is not known.
|
|
33
|
+
*/
|
|
34
|
+
function diagnosis(failure) {
|
|
35
|
+
switch (failure.klass) {
|
|
36
|
+
case 'apply-denied':
|
|
37
|
+
return [
|
|
38
|
+
'Why it is refused while the directory looks writable: that call is a MERGED write — the DACL and the',
|
|
39
|
+
'mandatory-integrity label go out as one `SetNamedSecurityInfoW`. The label lives in the SACL, and the',
|
|
40
|
+
"owner's implicit rights cover only READ_CONTROL and WRITE_DAC, so the label half additionally needs",
|
|
41
|
+
'WRITE_OWNER on the directory. A workspace created with `mkdir` normally inherits',
|
|
42
|
+
'"Authenticated Users: Modify" (`0x1301bf`) from the drive root — and that mask has neither right.',
|
|
43
|
+
'This is a directory ACL fact, not a token privilege: `whoami /priv` will not show it, and',
|
|
44
|
+
'SeSecurityPrivilege is the wrong lever here.',
|
|
45
|
+
].join('\n');
|
|
46
|
+
case 'read-denied':
|
|
47
|
+
return [
|
|
48
|
+
'Why it is refused: the harness could not even read the directory\'s security descriptor, so the grant',
|
|
49
|
+
'never got as far as writing one. That read wants READ_CONTROL, which the directory is not granting this',
|
|
50
|
+
'account either.',
|
|
51
|
+
].join('\n');
|
|
52
|
+
case 'apply-other':
|
|
53
|
+
return [
|
|
54
|
+
'Why it is refused: this is the same merged write, but the Win32 code is not ERROR_ACCESS_DENIED (5), so',
|
|
55
|
+
'the missing-rights story above does not apply verbatim — a missing path, a non-directory target, or a',
|
|
56
|
+
'filesystem that does not carry ACLs are all possibilities. The one-line fix below is safe to try; if the',
|
|
57
|
+
'code persists, it is a different failure and worth reporting with the code.',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build the advisory attached to the failing tool result.
|
|
63
|
+
* @param failure - the recognized failure.
|
|
64
|
+
* @param href - optional URL shown for the upstream thread.
|
|
65
|
+
* @returns the user-role notice text, with the fix commands ready to paste.
|
|
66
|
+
*/
|
|
67
|
+
export function advisoryText(failure, href) {
|
|
68
|
+
const path = failure.path ?? PLACEHOLDER;
|
|
69
|
+
const where = href === undefined ? `tracked upstream (discussions ${DISCUSSIONS})` : `tracked upstream: ${href}`;
|
|
70
|
+
return [
|
|
71
|
+
'Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.',
|
|
72
|
+
'',
|
|
73
|
+
'What was reported:',
|
|
74
|
+
` ${failureLine(failure)}`,
|
|
75
|
+
'',
|
|
76
|
+
diagnosis(failure),
|
|
77
|
+
'',
|
|
78
|
+
'Confirm the cause (unelevated) — `icacls` is a normal user command:',
|
|
79
|
+
` icacls "${path}"`,
|
|
80
|
+
'Look for an ACE that names YOUR OWN account (run `whoami` if unsure) with (F) / Full control.',
|
|
81
|
+
'If the strongest entry naming you is (M) / Modify, that is this failure.',
|
|
82
|
+
'',
|
|
83
|
+
'Fix it (unelevated, one line) and then run the command again:',
|
|
84
|
+
` PowerShell: icacls "${path}" /grant "$env:USERNAME:(OI)(CI)F"`,
|
|
85
|
+
` cmd: icacls "${path}" /grant "%USERNAME%:(OI)(CI)F"`,
|
|
86
|
+
'',
|
|
87
|
+
'How to read this: the harness documents the prerequisite (' + PREREQUISITE + ') and this',
|
|
88
|
+
'error does not name it yet, so the advice is delivered here instead. This is a stopgap, ' + where + '.',
|
|
89
|
+
'What it is NOT: this plugin neither edits ACLs nor elevates — the command above is yours to run.',
|
|
90
|
+
'Your file read/write tools still work; only sandboxed command execution is blocked.',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Build the pre-dispatch denial for the optional fail-fast half.
|
|
95
|
+
* @param failure - the recognized failure.
|
|
96
|
+
* @param observed - how many provisioning failures this agent has produced.
|
|
97
|
+
* @param denial - this denial's 1-based ordinal.
|
|
98
|
+
* @param maxDenials - the denial budget.
|
|
99
|
+
* @returns the corrective text the model receives in place of a tool result.
|
|
100
|
+
*/
|
|
101
|
+
export function denialText(failure, observed, denial, maxDenials) {
|
|
102
|
+
const suffix = maxDenials - denial;
|
|
103
|
+
return [
|
|
104
|
+
`Blocked by sandbox-grant-advisor: this exact call has already failed ${String(observed)} times with the`,
|
|
105
|
+
'same workspace-provisioning error, and the environment has not changed since:',
|
|
106
|
+
` ${failureLine(failure)}`,
|
|
107
|
+
'',
|
|
108
|
+
'Retrying cannot succeed — the sandbox cannot start a command until the directory grant applies.',
|
|
109
|
+
'Stop, and either apply the fix or hand the problem to the user:',
|
|
110
|
+
failure.path === undefined ? '' : ` icacls "${failure.path}" /grant "$env:USERNAME:(OI)(CI)F"`,
|
|
111
|
+
'',
|
|
112
|
+
suffix > 0
|
|
113
|
+
? `This is automatic block ${String(denial)} of ${String(maxDenials)}; after that the call is allowed again.`
|
|
114
|
+
: `This is automatic block ${String(denial)} of ${String(maxDenials)} — the last one; further identical calls are allowed again.`,
|
|
115
|
+
].filter(line => line !== '').join('\n');
|
|
116
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sandbox-grant-advisor`: turn a Windows ACL provisioning failure that has no
|
|
3
|
+
* path forward into a diagnosis the model — and the user reading the
|
|
4
|
+
* transcript — can act on.
|
|
5
|
+
*
|
|
6
|
+
* Three reports of one signature (`#7538`, `#7622`, `#7646`) describe the same
|
|
7
|
+
* shape: the host-side write grant for a sandboxed workspace cannot be applied,
|
|
8
|
+
* every sandboxed command then fails identically **before it runs**, and the
|
|
9
|
+
* error text is a bare Win32 line:
|
|
10
|
+
*
|
|
11
|
+
* SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
|
|
12
|
+
*
|
|
13
|
+
* The grant is materialized lazily on the first confined call and nothing is
|
|
14
|
+
* cached when it throws, so the failure repeats per command rather than once
|
|
15
|
+
* (850 calls / 39 sessions in `#7622`; 52,588 output tokens with no output in
|
|
16
|
+
* `#7538`). The `workspace-write` policy is simply unusable in such a
|
|
17
|
+
* workspace, and the remedy the backend documents — the directory must grant
|
|
18
|
+
* the caller `WRITE_OWNER` — never reaches the user, so sessions escape into
|
|
19
|
+
* `danger-full-access` or die on the model's output cap.
|
|
20
|
+
*
|
|
21
|
+
* ## Where it acts, and why there
|
|
22
|
+
*
|
|
23
|
+
* One listener on the public `tools/post-execute` waterfall
|
|
24
|
+
* (`@deepseek-ai/dsh-tools`). Admissibility was decided by which half of the
|
|
25
|
+
* defect this seam can reach: the failure text (the provider propagates its
|
|
26
|
+
* error unchanged, and the tool pipeline turns it into an `isError` result), an
|
|
27
|
+
* agent identity to attribute it to (`exec.agent`), and a channel that speaks
|
|
28
|
+
* to the model in the same step (`PostToolDecision`'s `additionalContexts`,
|
|
29
|
+
* a durable user-role message).
|
|
30
|
+
*
|
|
31
|
+
* `ctx.sandbox.confine(argv, policy, signal)` sees the failure too, and cannot
|
|
32
|
+
* do this: its signature carries no agent, so a wrapper could detect the
|
|
33
|
+
* condition and never deliver a word about it to the session that is stuck.
|
|
34
|
+
*
|
|
35
|
+
* ## What it does
|
|
36
|
+
*
|
|
37
|
+
* 1. **One durable advisory per agent.** On the first recognized provisioning
|
|
38
|
+
* failure, the failing tool result is enriched with a user-role notice that
|
|
39
|
+
* names the missing right (`WRITE_OWNER` on the directory, not
|
|
40
|
+
* `SeSecurityPrivilege`), gives the unelevated one-line `icacls` remedy, and
|
|
41
|
+
* gives the discriminator that separates a Modify-only directory from a
|
|
42
|
+
* wrong prerequisite. Attached through `additionalContexts`, so the model
|
|
43
|
+
* sees it beside the failure rather than only in a log the model never reads.
|
|
44
|
+
* 2. **An optional bounded fail-fast.** With `enforceAfter` set, a call this
|
|
45
|
+
* plugin has *watched fail* this way is refused at `tools/pre-execute` once
|
|
46
|
+
* the environment has failed at least that many times. It is off by default:
|
|
47
|
+
* the useful signal here is the diagnosis, and a plugin that blocks command
|
|
48
|
+
* execution for a reason it merely recognizes is a risk, not a feature. See
|
|
49
|
+
* the README for why the blocking half is deliberately narrow.
|
|
50
|
+
*
|
|
51
|
+
* ## Honest boundaries
|
|
52
|
+
*
|
|
53
|
+
* - **The Windows path cannot be witnessed on macOS**, where this plugin was
|
|
54
|
+
* built and tested. What is tested is the decision layer: classification,
|
|
55
|
+
* once-per-agent delivery, the fail-fast budget, and the wiring to the real
|
|
56
|
+
* `ToolRuntime` — against synthetic results carrying the producer's exact
|
|
57
|
+
* error shape, with the format taken from
|
|
58
|
+
* `packages/subprocess/win32-process/src/errors.ts`.
|
|
59
|
+
* - **It does not repair anything.** No ACL is written, no privilege is
|
|
60
|
+
* requested, nothing is elevated: the `icacls` line is the user's to run.
|
|
61
|
+
* - **It complements, rather than replaces, `repeat-guard-escalation`.** That
|
|
62
|
+
* guard keys on *call identity* (identical arguments retried); this one keys
|
|
63
|
+
* on the *environment signature*, which is how several different commands can
|
|
64
|
+
* share one cause. They can be mounted together.
|
|
65
|
+
* - **The real fix is upstream**: the failure should name the outstanding
|
|
66
|
+
* condition at the site that knows it (`grantWrite` computes
|
|
67
|
+
* `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
|
|
68
|
+
* false). This plugin is the stopgap.
|
|
69
|
+
*
|
|
70
|
+
* @module @argszero/cordis-plugin-sandbox-grant-advisor
|
|
71
|
+
*/
|
|
72
|
+
import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
73
|
+
import { advisoryText, denialText, DISCUSSIONS } from './advice.js';
|
|
74
|
+
import { classifyProvisioningFailure } from './signature.js';
|
|
75
|
+
import { callKey, observe, observeSuccess, recordAdvice, recordDenial, shouldDeny } from './state.js';
|
|
76
|
+
export const name = 'sandbox-grant-advisor';
|
|
77
|
+
/** The tool pipeline this plugin observes and (optionally) gates. */
|
|
78
|
+
export const inject = ['tools'];
|
|
79
|
+
/**
|
|
80
|
+
* The producer kind every message this plugin writes carries.
|
|
81
|
+
*
|
|
82
|
+
* It is deliberately its own kind rather than the retired `plugin` wrapper: the
|
|
83
|
+
* current session format admits only a producer-owned kind — a message whose
|
|
84
|
+
* `source.kind` is the string `plugin` is refused on the way in
|
|
85
|
+
* (`packages/session/session-format-v3-to-v4/src/message-sources.ts`) — and the
|
|
86
|
+
* source union is documented as merge-extensible, one kind per producer.
|
|
87
|
+
*/
|
|
88
|
+
export const SOURCE_KIND = 'sandbox-grant-advisor';
|
|
89
|
+
/** Default fail-fast threshold: 0, i.e. the blocking half is off. */
|
|
90
|
+
export const DEFAULT_ENFORCE_AFTER = 0;
|
|
91
|
+
/** Default denial budget once the blocking half is enabled. */
|
|
92
|
+
export const DEFAULT_MAX_DENIALS = 2;
|
|
93
|
+
/** Compile one `*`-wildcard pattern to an anchored RegExp; all else is literal. */
|
|
94
|
+
function wildcardToRegExp(pattern) {
|
|
95
|
+
const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, String.raw `\$&`);
|
|
96
|
+
return new RegExp(`^${escaped.replaceAll('*', '.*')}$`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validate a count-like option fail-loud, so a typo cannot silently disable the
|
|
100
|
+
* blocking half the operator asked for.
|
|
101
|
+
* @param label - the option name, for the message.
|
|
102
|
+
* @param value - the resolved value.
|
|
103
|
+
* @param minimum - the smallest legal value.
|
|
104
|
+
* @returns the value, once validated.
|
|
105
|
+
*/
|
|
106
|
+
function integerAtLeast(label, value, minimum) {
|
|
107
|
+
if (!Number.isInteger(value) || value < minimum) {
|
|
108
|
+
throw new Error(`sandbox-grant-advisor: \`${label}\` must be an integer >= ${minimum} (got ${String(value)})`);
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The plain text of a failed result, from the authoritative field first.
|
|
114
|
+
*
|
|
115
|
+
* `error.message` is what the producing layer recorded and survives content
|
|
116
|
+
* rewriting by other post-execute listeners; the rendered text is the fallback,
|
|
117
|
+
* so a result whose content was replaced (spill policies, hooks) is still
|
|
118
|
+
* classified from its message.
|
|
119
|
+
* @param result - the failed tool result.
|
|
120
|
+
* @returns the text to classify.
|
|
121
|
+
*/
|
|
122
|
+
function failureText(result) {
|
|
123
|
+
const rendered = result.content
|
|
124
|
+
.filter(block => block.type === 'text')
|
|
125
|
+
.map(block => block.text)
|
|
126
|
+
.join('\n');
|
|
127
|
+
return result.error.message.length > 0 ? `${result.error.message}\n${rendered}` : rendered;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The one-line host-side account of a recognized failure.
|
|
131
|
+
* @param failure - the recognized failure.
|
|
132
|
+
* @returns a single log line.
|
|
133
|
+
*/
|
|
134
|
+
function hostLine(failure) {
|
|
135
|
+
const where = failure.detail.length === 0 ? '' : ` at ${failure.detail}`;
|
|
136
|
+
return `sandbox-grant-advisor: workspace ACL provisioning failed (${failure.api} Win32 `
|
|
137
|
+
+ `${String(failure.win32Code)})${where} — sandboxed commands will keep failing until the directory grants `
|
|
138
|
+
+ `this account Full control; advisory delivered to the model (discussions ${DISCUSSIONS})`;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Wrap one notice as a user-role message.
|
|
142
|
+
*
|
|
143
|
+
* The double cast encodes a documented fact the installed type cannot express:
|
|
144
|
+
* the message source union is **merge-extensible** — "each producer declares its
|
|
145
|
+
* own `kind` in its own module; there is no shared catch-all `plugin` kind", and
|
|
146
|
+
* "consumers fall through unknown kinds" — while the union shipped in the peer
|
|
147
|
+
* package is a closed list written before this producer existed. A plugin cannot
|
|
148
|
+
* augment an interface it does not own, and the session format admits any
|
|
149
|
+
* non-empty kind except the retired `plugin` wrapper
|
|
150
|
+
* (`packages/session/session-format-v3-to-v4/src/message-sources.ts`), which is
|
|
151
|
+
* asserted by `test/plugin.spec.mjs` against the message this function returns.
|
|
152
|
+
* @param text - the notice body.
|
|
153
|
+
* @param summary - one-line account for the transcript row.
|
|
154
|
+
* @returns the message, identified and frozen by the harness factory.
|
|
155
|
+
*/
|
|
156
|
+
function notice(text, summary) {
|
|
157
|
+
return createUserMessage({
|
|
158
|
+
content: [{ type: 'text', text }],
|
|
159
|
+
source: {
|
|
160
|
+
kind: SOURCE_KIND,
|
|
161
|
+
form: 'notice',
|
|
162
|
+
summary: boundContextSummary(summary),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** Keep this plugin's notices ahead of any other context on the same result. */
|
|
167
|
+
function prepend(ours, theirs) {
|
|
168
|
+
return [ours, ...theirs ?? []];
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Install the advisor.
|
|
172
|
+
* @param ctx - context carrying the tool pipeline.
|
|
173
|
+
* @param config - resolved options; validated fail-loud here.
|
|
174
|
+
*/
|
|
175
|
+
export function apply(ctx, config = {}) {
|
|
176
|
+
const enforceAfter = integerAtLeast('enforceAfter', config.enforceAfter ?? DEFAULT_ENFORCE_AFTER, 0);
|
|
177
|
+
const maxDenials = integerAtLeast('maxDenials', config.maxDenials ?? DEFAULT_MAX_DENIALS, 1);
|
|
178
|
+
const includePatterns = (config.include ?? []).map(wildcardToRegExp);
|
|
179
|
+
const excludePatterns = (config.exclude ?? []).map(wildcardToRegExp);
|
|
180
|
+
const href = config.href;
|
|
181
|
+
/** One state per agent; a WeakMap keeps a finished agent's state collectable. */
|
|
182
|
+
const states = new WeakMap();
|
|
183
|
+
/**
|
|
184
|
+
* The executions this plugin denied, so the post-execute listener never reads
|
|
185
|
+
* its own denial as an environment failure. A denial's text quotes the Win32
|
|
186
|
+
* line on purpose (that is what the model must see), which makes it
|
|
187
|
+
* indistinguishable from the real thing by content alone — the identity of
|
|
188
|
+
* the execution object, shared by reference across both seams, is what
|
|
189
|
+
* separates them.
|
|
190
|
+
*/
|
|
191
|
+
const ownDenials = new WeakSet();
|
|
192
|
+
/** Whether a tool participates; untracked calls are transparent. */
|
|
193
|
+
function tracked(toolName) {
|
|
194
|
+
if (includePatterns.length > 0 && !includePatterns.some(pattern => pattern.test(toolName)))
|
|
195
|
+
return false;
|
|
196
|
+
return !excludePatterns.some(pattern => pattern.test(toolName));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Read one settled call: advance the state, and decide whether it is the
|
|
200
|
+
* failure the model needs told about.
|
|
201
|
+
* @param exec - the call that just ran.
|
|
202
|
+
* @param result - its settled outcome.
|
|
203
|
+
* @returns the notice to attach, or undefined.
|
|
204
|
+
*/
|
|
205
|
+
function inspect(exec, result) {
|
|
206
|
+
const agent = exec.agent;
|
|
207
|
+
if (agent === undefined || !tracked(exec.name))
|
|
208
|
+
return undefined;
|
|
209
|
+
if (ownDenials.has(exec)) {
|
|
210
|
+
ownDenials.delete(exec);
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
const key = callKey(exec.name, exec.arguments);
|
|
214
|
+
const previous = states.get(agent);
|
|
215
|
+
if (result.isError !== true) {
|
|
216
|
+
if (previous !== undefined)
|
|
217
|
+
states.set(agent, observeSuccess(previous, key));
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
const failure = classifyProvisioningFailure(failureText(result));
|
|
221
|
+
if (failure === undefined)
|
|
222
|
+
return undefined;
|
|
223
|
+
const advanced = observe(previous, failure, key);
|
|
224
|
+
if (advanced.advised) {
|
|
225
|
+
states.set(agent, advanced);
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
states.set(agent, recordAdvice(advanced));
|
|
229
|
+
ctx.logger.warn(hostLine(failure));
|
|
230
|
+
return notice(advisoryText(failure, href), `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`);
|
|
231
|
+
}
|
|
232
|
+
// Observe-and-enrich, never veto by itself: delegate first, then fold this
|
|
233
|
+
// plugin's notice onto whatever came back. `additionalContexts` rides both
|
|
234
|
+
// decision variants, so a result another listener blocked still carries the
|
|
235
|
+
// diagnosis.
|
|
236
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
237
|
+
let message;
|
|
238
|
+
try {
|
|
239
|
+
message = inspect(exec, result);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
// A broken advisor must not become a broken tool call.
|
|
243
|
+
ctx.logger.warn(`sandbox-grant-advisor: result left alone after internal error: ${String(error)}`);
|
|
244
|
+
}
|
|
245
|
+
const downstream = await next();
|
|
246
|
+
if (message === undefined)
|
|
247
|
+
return downstream;
|
|
248
|
+
if (downstream.kind === 'block') {
|
|
249
|
+
return {
|
|
250
|
+
kind: 'block',
|
|
251
|
+
feedback: downstream.feedback,
|
|
252
|
+
additionalContexts: prepend(message, downstream.additionalContexts),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return { ...downstream, additionalContexts: prepend(message, downstream.additionalContexts) };
|
|
256
|
+
});
|
|
257
|
+
// The optional blocking half. Registered only when asked for: with the default
|
|
258
|
+
// `enforceAfter: 0` this plugin never sits in a waterfall it can veto from.
|
|
259
|
+
if (enforceAfter === 0)
|
|
260
|
+
return;
|
|
261
|
+
ctx.on('tools/pre-execute', (exec, next) => {
|
|
262
|
+
try {
|
|
263
|
+
const agent = exec.agent;
|
|
264
|
+
if (agent === undefined || !tracked(exec.name))
|
|
265
|
+
return next();
|
|
266
|
+
const state = states.get(agent);
|
|
267
|
+
if (!shouldDeny(state, callKey(exec.name, exec.arguments), enforceAfter, maxDenials))
|
|
268
|
+
return next();
|
|
269
|
+
if (state === undefined)
|
|
270
|
+
return next();
|
|
271
|
+
// Tag before delegating, and spend the budget immediately: the denial must
|
|
272
|
+
// be accounted for even if a later listener replaces this decision.
|
|
273
|
+
ownDenials.add(exec);
|
|
274
|
+
states.set(agent, recordDenial(state));
|
|
275
|
+
return Promise.resolve({
|
|
276
|
+
kind: 'deny',
|
|
277
|
+
reason: denialText(state.last, state.observations, state.denials + 1, maxDenials),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
ctx.logger.warn(`sandbox-grant-advisor: call allowed after internal error: ${String(error)}`);
|
|
282
|
+
return next();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
package/lib/signature.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognize the Windows ACL provisioning failure that has no path forward.
|
|
3
|
+
*
|
|
4
|
+
* The harness's Windows sandbox provisions a workspace by writing the
|
|
5
|
+
* directory's DACL and its mandatory-integrity label in **one**
|
|
6
|
+
* `SetNamedSecurityInfoW` call (`packages/sandbox/sandbox-windows-acl/src/acl.ts`).
|
|
7
|
+
* When that call is refused, the error a session actually sees is the bare
|
|
8
|
+
* Win32 string — `SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)` —
|
|
9
|
+
* with no statement of which right was missing or what the caller can do about
|
|
10
|
+
* it. Every sandboxed command then fails the same way, forever, because the
|
|
11
|
+
* grant is materialized lazily and nothing is cached on the failure path.
|
|
12
|
+
*
|
|
13
|
+
* Recognizing the string is therefore the whole job of this module, and the
|
|
14
|
+
* recognition is deliberately narrow:
|
|
15
|
+
*
|
|
16
|
+
* - **Only the two `...NamedSecurityInfoW` operations are classified.** Their
|
|
17
|
+
* failures are the provisioning path. `SetEntriesInAclW` merges entries in
|
|
18
|
+
* process memory (no object, no rights), and the `LocalFree` /
|
|
19
|
+
* `SetConsoleCtrlHandler` / `LockFileEx` failures in the same package are
|
|
20
|
+
* allocation or lock errors — advising an ACL fix for any of those would send
|
|
21
|
+
* a user to change the wrong thing. A classifier that names a wrong cause is
|
|
22
|
+
* worse than one that stays silent.
|
|
23
|
+
* - **The Win32 code is kept, not flattened.** `ERROR_ACCESS_DENIED` (5) is the
|
|
24
|
+
* case the documented prerequisite explains; another code is a different
|
|
25
|
+
* story and the advisory says so instead of borrowing the same sentence.
|
|
26
|
+
* - **The producer's detail is preserved verbatim** (`grantWrite(D:\ws)`), so
|
|
27
|
+
* the advisory can quote the exact line the model and the user are looking
|
|
28
|
+
* at, and the path can be re-used in the fix command.
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* The producer's format is fixed by `Win32Error`
|
|
34
|
+
* (`packages/subprocess/win32-process/src/errors.ts`):
|
|
35
|
+
* `` `${api} failed (Win32 ${code})${detail ? `: ${detail}` : ''}` ``.
|
|
36
|
+
* The pattern is written against that shape, but it is anchored on the API
|
|
37
|
+
* names rather than on surrounding text, so it survives the `Error: ` envelope
|
|
38
|
+
* a tool result adds and any prefix a provider wraps around it.
|
|
39
|
+
*/
|
|
40
|
+
const SIGNATURE = /\b(SetNamedSecurityInfoW|GetNamedSecurityInfoW) failed \(Win32 (\d+)\)(?:: *([^\r\n]*))?/;
|
|
41
|
+
/** `ERROR_ACCESS_DENIED`. */
|
|
42
|
+
const ACCESS_DENIED = 5;
|
|
43
|
+
/** Split the producer's `label(path)` detail; any other shape yields nothing. */
|
|
44
|
+
function splitDetail(detail) {
|
|
45
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*)\((.*)\)$/.exec(detail.trim());
|
|
46
|
+
if (match === null)
|
|
47
|
+
return {};
|
|
48
|
+
const label = match[1];
|
|
49
|
+
const path = match[2];
|
|
50
|
+
if (label === undefined || path === undefined)
|
|
51
|
+
return {};
|
|
52
|
+
return { label, path };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Classify one failure message.
|
|
56
|
+
* @param message - the failure text, from the result's `error.message` or its rendered content.
|
|
57
|
+
* @returns the recognized failure, or undefined when this is not a provisioning failure.
|
|
58
|
+
*/
|
|
59
|
+
export function classifyProvisioningFailure(message) {
|
|
60
|
+
const match = SIGNATURE.exec(message);
|
|
61
|
+
if (match === null)
|
|
62
|
+
return undefined;
|
|
63
|
+
const api = match[1];
|
|
64
|
+
const code = Number(match[2]);
|
|
65
|
+
if (api === undefined || !Number.isInteger(code))
|
|
66
|
+
return undefined;
|
|
67
|
+
const detail = (match[3] ?? '').trim();
|
|
68
|
+
const klass = api === 'GetNamedSecurityInfoW'
|
|
69
|
+
? 'read-denied'
|
|
70
|
+
: code === ACCESS_DENIED ? 'apply-denied' : 'apply-other';
|
|
71
|
+
return { klass, api, win32Code: code, detail, ...splitDetail(detail) };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The one-line failure the producer wrote, for quoting back verbatim.
|
|
75
|
+
* @param failure - a recognized failure.
|
|
76
|
+
* @returns the message text a `Win32Error` would have produced.
|
|
77
|
+
*/
|
|
78
|
+
export function failureLine(failure) {
|
|
79
|
+
const suffix = failure.detail.length === 0 ? '' : `: ${failure.detail}`;
|
|
80
|
+
return `${failure.api} failed (Win32 ${failure.win32Code})${suffix}`;
|
|
81
|
+
}
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent bookkeeping: what this environment has already been told, and (in
|
|
3
|
+
* the optional fail-fast half) which calls have already been refused by it.
|
|
4
|
+
*
|
|
5
|
+
* The state is deliberately keyed by **agent**, not by session id string: the
|
|
6
|
+
* failing call carries `exec.agent`, one agent owns one session, and a WeakMap
|
|
7
|
+
* keyed by the agent object lets a finished session's state be collected.
|
|
8
|
+
*
|
|
9
|
+
* Two counters, two meanings — keeping them apart is what stops the plugin from
|
|
10
|
+
* feeding on itself:
|
|
11
|
+
*
|
|
12
|
+
* - `observations` counts *provisioning failures of this environment*, i.e.
|
|
13
|
+
* tool results the environment itself produced. A call this plugin denied is
|
|
14
|
+
* not one of them, even though its denial text quotes the Win32 line.
|
|
15
|
+
* - `failingKeys` holds the call identities (tool + canonical arguments) that
|
|
16
|
+
* have already failed this way. The fail-fast half may only refuse a call it
|
|
17
|
+
* has *watched fail* — never a call it merely recognizes as similar.
|
|
18
|
+
*
|
|
19
|
+
* `denials` is spent per *episode*: it is re-armed when a watched call finally
|
|
20
|
+
* succeeds (see `observeSuccess`), not carried for the whole session.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Canonicalize a parsed argument value into a stable string.
|
|
26
|
+
*
|
|
27
|
+
* Key order in a JavaScript object is insertion order, so two structurally
|
|
28
|
+
* identical calls can serialize differently depending on how the model ordered
|
|
29
|
+
* its JSON. Sorting keys recursively gives the identity the fail-fast half
|
|
30
|
+
* needs; unsupported values (functions, symbols, cycles) fall back to a type
|
|
31
|
+
* tag rather than throwing, because a guard must never be the reason a call
|
|
32
|
+
* dies.
|
|
33
|
+
* @param value - the parsed tool arguments.
|
|
34
|
+
* @returns a stable string.
|
|
35
|
+
*/
|
|
36
|
+
export function canonicalize(value) {
|
|
37
|
+
const seen = new WeakSet();
|
|
38
|
+
const walk = (node, depth) => {
|
|
39
|
+
if (depth > 32)
|
|
40
|
+
return '<depth>';
|
|
41
|
+
if (node === null || typeof node !== 'object') {
|
|
42
|
+
return typeof node === 'bigint' ? `${node.toString()}n` : node;
|
|
43
|
+
}
|
|
44
|
+
if (seen.has(node))
|
|
45
|
+
return '<cycle>';
|
|
46
|
+
seen.add(node);
|
|
47
|
+
if (Array.isArray(node))
|
|
48
|
+
return node.map(item => walk(item, depth + 1));
|
|
49
|
+
const entries = Object.entries(node)
|
|
50
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
51
|
+
.map(([key, item]) => [key, walk(item, depth + 1)]);
|
|
52
|
+
return Object.fromEntries(entries);
|
|
53
|
+
};
|
|
54
|
+
return JSON.stringify(walk(value, 0)) ?? '<unserializable>';
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The identity of one call: its tool name plus its canonical arguments.
|
|
58
|
+
* @param name - the tool name.
|
|
59
|
+
* @param args - the parsed arguments.
|
|
60
|
+
* @returns the identity key.
|
|
61
|
+
*/
|
|
62
|
+
export function callKey(name, args) {
|
|
63
|
+
return `${name}(${canonicalize(args)})`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Record one observed provisioning failure.
|
|
67
|
+
* @param state - the agent's current state, or undefined on first sight.
|
|
68
|
+
* @param failure - the recognized failure.
|
|
69
|
+
* @param key - the identity of the call that failed.
|
|
70
|
+
* @returns the updated state.
|
|
71
|
+
*/
|
|
72
|
+
export function observe(state, failure, key) {
|
|
73
|
+
const failingKeys = new Set(state?.failingKeys ?? []);
|
|
74
|
+
failingKeys.add(key);
|
|
75
|
+
return {
|
|
76
|
+
observations: (state?.observations ?? 0) + 1,
|
|
77
|
+
last: failure,
|
|
78
|
+
failingKeys,
|
|
79
|
+
denials: state?.denials ?? 0,
|
|
80
|
+
advised: state?.advised ?? false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Record that a call carrying the same identity as a previously failing one
|
|
85
|
+
* succeeded. The environment worked at least once for that call, so the entry
|
|
86
|
+
* stops justifying a denial — and is dropped rather than kept, so a later
|
|
87
|
+
* failure re-earns it.
|
|
88
|
+
*
|
|
89
|
+
* The denial budget is re-armed at the same moment, and only then. Measured
|
|
90
|
+
* consequence: without it the budget is per agent for the whole session, which
|
|
91
|
+
* makes the entry above unobservable — past `maxDenials` this plugin refuses
|
|
92
|
+
* nothing ever again, so clearing the key would change no decision. With it the
|
|
93
|
+
* bound reads as "at most `maxDenials` refusals per episode of brokenness": an
|
|
94
|
+
* environment that breaks, is repaired and breaks again may be refused again,
|
|
95
|
+
* while a session can always make progress by spending the budget.
|
|
96
|
+
* @param state - the agent's current state.
|
|
97
|
+
* @param key - the identity of the call that just succeeded.
|
|
98
|
+
* @returns the updated state, unchanged when the key was not failing.
|
|
99
|
+
*/
|
|
100
|
+
export function observeSuccess(state, key) {
|
|
101
|
+
if (!state.failingKeys.has(key))
|
|
102
|
+
return state;
|
|
103
|
+
const failingKeys = new Set(state.failingKeys);
|
|
104
|
+
failingKeys.delete(key);
|
|
105
|
+
return { ...state, failingKeys, denials: 0 };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether a call may be refused before dispatch.
|
|
109
|
+
*
|
|
110
|
+
* Both conditions are required: the environment has failed provisioning at
|
|
111
|
+
* least `enforceAfter` times, **and** this exact call is one this plugin watched
|
|
112
|
+
* fail. The second condition is what keeps the fail-fast half from blocking a
|
|
113
|
+
* workaround: a different command, or the same command under a different policy
|
|
114
|
+
* after the user changed configuration, has no key here.
|
|
115
|
+
* @param state - the agent's current state, or undefined.
|
|
116
|
+
* @param key - the identity of the call about to dispatch.
|
|
117
|
+
* @param enforceAfter - the configured threshold; 0 disables the half entirely.
|
|
118
|
+
* @param maxDenials - the configured denial budget.
|
|
119
|
+
* @returns whether to deny.
|
|
120
|
+
*/
|
|
121
|
+
export function shouldDeny(state, key, enforceAfter, maxDenials) {
|
|
122
|
+
if (enforceAfter === 0 || state === undefined)
|
|
123
|
+
return false;
|
|
124
|
+
if (state.observations < enforceAfter)
|
|
125
|
+
return false;
|
|
126
|
+
if (state.denials >= maxDenials)
|
|
127
|
+
return false;
|
|
128
|
+
return state.failingKeys.has(key);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Spend one denial.
|
|
132
|
+
* @param state - the agent's current state.
|
|
133
|
+
* @returns the updated state.
|
|
134
|
+
*/
|
|
135
|
+
export function recordDenial(state) {
|
|
136
|
+
return { ...state, denials: state.denials + 1 };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Mark the durable advisory as delivered.
|
|
140
|
+
* @param state - the agent's current state.
|
|
141
|
+
* @returns the updated state.
|
|
142
|
+
*/
|
|
143
|
+
export function recordAdvice(state) {
|
|
144
|
+
return { ...state, advised: true };
|
|
145
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the model — and through it the user — is told about a provisioning
|
|
3
|
+
* failure, and what is deliberately withheld.
|
|
4
|
+
*
|
|
5
|
+
* The text is assembled here as pure functions so every sentence can be pinned
|
|
6
|
+
* by a test. Two properties matter more than the wording:
|
|
7
|
+
*
|
|
8
|
+
* - **It names the right the caller is missing.** The reported failures are
|
|
9
|
+
* `ERROR_ACCESS_DENIED` from a *merged* DACL + SACL write. The missing right
|
|
10
|
+
* is `WRITE_OWNER` on the directory — an object right the caller can grant
|
|
11
|
+
* itself with `icacls`, unelevated. It is **not** `SeSecurityPrivilege`, the
|
|
12
|
+
* token privilege the reports naturally reach for; `whoami /priv` cannot show
|
|
13
|
+
* the difference, and elevation is the wrong lever.
|
|
14
|
+
* - **It gives a discriminator, not just a remedy.** Applying a fix without
|
|
15
|
+
* confirming the cause teaches nothing when the fix does not work. The
|
|
16
|
+
* one-line check (`icacls <dir>`, looking for an ACE that names the caller's
|
|
17
|
+
* own SID and grants `(F)`) separates "Modify-only directory" from "the
|
|
18
|
+
* documented prerequisite is wrong", which is the open question upstream.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
import type { ProvisioningFailure } from './signature.js';
|
|
23
|
+
/** The upstream threads this advisory is a stopgap for. */
|
|
24
|
+
export declare const DISCUSSIONS = "#7538 / #7622 / #7646";
|
|
25
|
+
/** The documented prerequisite, quoted from the backend's README. */
|
|
26
|
+
export declare const PREREQUISITE = "granted directories must be caller-owned and grant `WRITE_OWNER`";
|
|
27
|
+
/**
|
|
28
|
+
* Build the advisory attached to the failing tool result.
|
|
29
|
+
* @param failure - the recognized failure.
|
|
30
|
+
* @param href - optional URL shown for the upstream thread.
|
|
31
|
+
* @returns the user-role notice text, with the fix commands ready to paste.
|
|
32
|
+
*/
|
|
33
|
+
export declare function advisoryText(failure: ProvisioningFailure, href?: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Build the pre-dispatch denial for the optional fail-fast half.
|
|
36
|
+
* @param failure - the recognized failure.
|
|
37
|
+
* @param observed - how many provisioning failures this agent has produced.
|
|
38
|
+
* @param denial - this denial's 1-based ordinal.
|
|
39
|
+
* @param maxDenials - the denial budget.
|
|
40
|
+
* @returns the corrective text the model receives in place of a tool result.
|
|
41
|
+
*/
|
|
42
|
+
export declare function denialText(failure: ProvisioningFailure, observed: number, denial: number, maxDenials: number): string;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sandbox-grant-advisor`: turn a Windows ACL provisioning failure that has no
|
|
3
|
+
* path forward into a diagnosis the model — and the user reading the
|
|
4
|
+
* transcript — can act on.
|
|
5
|
+
*
|
|
6
|
+
* Three reports of one signature (`#7538`, `#7622`, `#7646`) describe the same
|
|
7
|
+
* shape: the host-side write grant for a sandboxed workspace cannot be applied,
|
|
8
|
+
* every sandboxed command then fails identically **before it runs**, and the
|
|
9
|
+
* error text is a bare Win32 line:
|
|
10
|
+
*
|
|
11
|
+
* SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
|
|
12
|
+
*
|
|
13
|
+
* The grant is materialized lazily on the first confined call and nothing is
|
|
14
|
+
* cached when it throws, so the failure repeats per command rather than once
|
|
15
|
+
* (850 calls / 39 sessions in `#7622`; 52,588 output tokens with no output in
|
|
16
|
+
* `#7538`). The `workspace-write` policy is simply unusable in such a
|
|
17
|
+
* workspace, and the remedy the backend documents — the directory must grant
|
|
18
|
+
* the caller `WRITE_OWNER` — never reaches the user, so sessions escape into
|
|
19
|
+
* `danger-full-access` or die on the model's output cap.
|
|
20
|
+
*
|
|
21
|
+
* ## Where it acts, and why there
|
|
22
|
+
*
|
|
23
|
+
* One listener on the public `tools/post-execute` waterfall
|
|
24
|
+
* (`@deepseek-ai/dsh-tools`). Admissibility was decided by which half of the
|
|
25
|
+
* defect this seam can reach: the failure text (the provider propagates its
|
|
26
|
+
* error unchanged, and the tool pipeline turns it into an `isError` result), an
|
|
27
|
+
* agent identity to attribute it to (`exec.agent`), and a channel that speaks
|
|
28
|
+
* to the model in the same step (`PostToolDecision`'s `additionalContexts`,
|
|
29
|
+
* a durable user-role message).
|
|
30
|
+
*
|
|
31
|
+
* `ctx.sandbox.confine(argv, policy, signal)` sees the failure too, and cannot
|
|
32
|
+
* do this: its signature carries no agent, so a wrapper could detect the
|
|
33
|
+
* condition and never deliver a word about it to the session that is stuck.
|
|
34
|
+
*
|
|
35
|
+
* ## What it does
|
|
36
|
+
*
|
|
37
|
+
* 1. **One durable advisory per agent.** On the first recognized provisioning
|
|
38
|
+
* failure, the failing tool result is enriched with a user-role notice that
|
|
39
|
+
* names the missing right (`WRITE_OWNER` on the directory, not
|
|
40
|
+
* `SeSecurityPrivilege`), gives the unelevated one-line `icacls` remedy, and
|
|
41
|
+
* gives the discriminator that separates a Modify-only directory from a
|
|
42
|
+
* wrong prerequisite. Attached through `additionalContexts`, so the model
|
|
43
|
+
* sees it beside the failure rather than only in a log the model never reads.
|
|
44
|
+
* 2. **An optional bounded fail-fast.** With `enforceAfter` set, a call this
|
|
45
|
+
* plugin has *watched fail* this way is refused at `tools/pre-execute` once
|
|
46
|
+
* the environment has failed at least that many times. It is off by default:
|
|
47
|
+
* the useful signal here is the diagnosis, and a plugin that blocks command
|
|
48
|
+
* execution for a reason it merely recognizes is a risk, not a feature. See
|
|
49
|
+
* the README for why the blocking half is deliberately narrow.
|
|
50
|
+
*
|
|
51
|
+
* ## Honest boundaries
|
|
52
|
+
*
|
|
53
|
+
* - **The Windows path cannot be witnessed on macOS**, where this plugin was
|
|
54
|
+
* built and tested. What is tested is the decision layer: classification,
|
|
55
|
+
* once-per-agent delivery, the fail-fast budget, and the wiring to the real
|
|
56
|
+
* `ToolRuntime` — against synthetic results carrying the producer's exact
|
|
57
|
+
* error shape, with the format taken from
|
|
58
|
+
* `packages/subprocess/win32-process/src/errors.ts`.
|
|
59
|
+
* - **It does not repair anything.** No ACL is written, no privilege is
|
|
60
|
+
* requested, nothing is elevated: the `icacls` line is the user's to run.
|
|
61
|
+
* - **It complements, rather than replaces, `repeat-guard-escalation`.** That
|
|
62
|
+
* guard keys on *call identity* (identical arguments retried); this one keys
|
|
63
|
+
* on the *environment signature*, which is how several different commands can
|
|
64
|
+
* share one cause. They can be mounted together.
|
|
65
|
+
* - **The real fix is upstream**: the failure should name the outstanding
|
|
66
|
+
* condition at the site that knows it (`grantWrite` computes
|
|
67
|
+
* `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
|
|
68
|
+
* false). This plugin is the stopgap.
|
|
69
|
+
*
|
|
70
|
+
* @module @argszero/cordis-plugin-sandbox-grant-advisor
|
|
71
|
+
*/
|
|
72
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
73
|
+
export declare const name = "sandbox-grant-advisor";
|
|
74
|
+
/** The tool pipeline this plugin observes and (optionally) gates. */
|
|
75
|
+
export declare const inject: string[];
|
|
76
|
+
/**
|
|
77
|
+
* The producer kind every message this plugin writes carries.
|
|
78
|
+
*
|
|
79
|
+
* It is deliberately its own kind rather than the retired `plugin` wrapper: the
|
|
80
|
+
* current session format admits only a producer-owned kind — a message whose
|
|
81
|
+
* `source.kind` is the string `plugin` is refused on the way in
|
|
82
|
+
* (`packages/session/session-format-v3-to-v4/src/message-sources.ts`) — and the
|
|
83
|
+
* source union is documented as merge-extensible, one kind per producer.
|
|
84
|
+
*/
|
|
85
|
+
export declare const SOURCE_KIND = "sandbox-grant-advisor";
|
|
86
|
+
/** Default fail-fast threshold: 0, i.e. the blocking half is off. */
|
|
87
|
+
export declare const DEFAULT_ENFORCE_AFTER = 0;
|
|
88
|
+
/** Default denial budget once the blocking half is enabled. */
|
|
89
|
+
export declare const DEFAULT_MAX_DENIALS = 2;
|
|
90
|
+
/** Configures what is watched and whether the blocking half runs. */
|
|
91
|
+
export interface Config {
|
|
92
|
+
/**
|
|
93
|
+
* Provisioning failures after which an identical, already-failing call is
|
|
94
|
+
* denied before dispatch. `0` (the default) disables the half entirely; the
|
|
95
|
+
* advisory half is unaffected and always on.
|
|
96
|
+
*/
|
|
97
|
+
enforceAfter?: number;
|
|
98
|
+
/**
|
|
99
|
+
* How many denials one agent may spend. Defaults to 2. Bounded on purpose:
|
|
100
|
+
* an unbounded refusal turns a stuck session into an unfinishable one.
|
|
101
|
+
*/
|
|
102
|
+
maxDenials?: number;
|
|
103
|
+
/** Tool-name wildcard patterns to watch; empty means every tool. */
|
|
104
|
+
include?: string[];
|
|
105
|
+
/** Tool-name wildcard patterns never watched. */
|
|
106
|
+
exclude?: string[];
|
|
107
|
+
/** URL quoted in the advisory as the upstream thread; optional. */
|
|
108
|
+
href?: string;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Install the advisor.
|
|
112
|
+
* @param ctx - context carrying the tool pipeline.
|
|
113
|
+
* @param config - resolved options; validated fail-loud here.
|
|
114
|
+
*/
|
|
115
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognize the Windows ACL provisioning failure that has no path forward.
|
|
3
|
+
*
|
|
4
|
+
* The harness's Windows sandbox provisions a workspace by writing the
|
|
5
|
+
* directory's DACL and its mandatory-integrity label in **one**
|
|
6
|
+
* `SetNamedSecurityInfoW` call (`packages/sandbox/sandbox-windows-acl/src/acl.ts`).
|
|
7
|
+
* When that call is refused, the error a session actually sees is the bare
|
|
8
|
+
* Win32 string — `SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)` —
|
|
9
|
+
* with no statement of which right was missing or what the caller can do about
|
|
10
|
+
* it. Every sandboxed command then fails the same way, forever, because the
|
|
11
|
+
* grant is materialized lazily and nothing is cached on the failure path.
|
|
12
|
+
*
|
|
13
|
+
* Recognizing the string is therefore the whole job of this module, and the
|
|
14
|
+
* recognition is deliberately narrow:
|
|
15
|
+
*
|
|
16
|
+
* - **Only the two `...NamedSecurityInfoW` operations are classified.** Their
|
|
17
|
+
* failures are the provisioning path. `SetEntriesInAclW` merges entries in
|
|
18
|
+
* process memory (no object, no rights), and the `LocalFree` /
|
|
19
|
+
* `SetConsoleCtrlHandler` / `LockFileEx` failures in the same package are
|
|
20
|
+
* allocation or lock errors — advising an ACL fix for any of those would send
|
|
21
|
+
* a user to change the wrong thing. A classifier that names a wrong cause is
|
|
22
|
+
* worse than one that stays silent.
|
|
23
|
+
* - **The Win32 code is kept, not flattened.** `ERROR_ACCESS_DENIED` (5) is the
|
|
24
|
+
* case the documented prerequisite explains; another code is a different
|
|
25
|
+
* story and the advisory says so instead of borrowing the same sentence.
|
|
26
|
+
* - **The producer's detail is preserved verbatim** (`grantWrite(D:\ws)`), so
|
|
27
|
+
* the advisory can quote the exact line the model and the user are looking
|
|
28
|
+
* at, and the path can be re-used in the fix command.
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
*/
|
|
32
|
+
/** Which provisioning operation failed, and which diagnosis follows from it. */
|
|
33
|
+
export type FailureClass =
|
|
34
|
+
/** `SetNamedSecurityInfoW` returned `ERROR_ACCESS_DENIED` (5): the merged DACL + label write was refused. */
|
|
35
|
+
'apply-denied'
|
|
36
|
+
/** `SetNamedSecurityInfoW` failed with a Win32 code other than `ERROR_ACCESS_DENIED`. */
|
|
37
|
+
| 'apply-other'
|
|
38
|
+
/** `GetNamedSecurityInfoW` failed: the security descriptor could not even be read. */
|
|
39
|
+
| 'read-denied';
|
|
40
|
+
/** One recognized provisioning failure, with the producer's own fields kept. */
|
|
41
|
+
export interface ProvisioningFailure {
|
|
42
|
+
/** Which diagnosis follows from the api/code pair. */
|
|
43
|
+
readonly klass: FailureClass;
|
|
44
|
+
/** The API whose checked result failed, exactly as the producer names it. */
|
|
45
|
+
readonly api: string;
|
|
46
|
+
/** The Win32 error code as reported. */
|
|
47
|
+
readonly win32Code: number;
|
|
48
|
+
/** The producer's detail, e.g. `grantWrite(D:\ws)`; empty when it supplied none. */
|
|
49
|
+
readonly detail: string;
|
|
50
|
+
/** The detail's `label(...)` head, when it has that shape. */
|
|
51
|
+
readonly label?: string;
|
|
52
|
+
/** The directory the detail names, when it has that shape. */
|
|
53
|
+
readonly path?: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Classify one failure message.
|
|
57
|
+
* @param message - the failure text, from the result's `error.message` or its rendered content.
|
|
58
|
+
* @returns the recognized failure, or undefined when this is not a provisioning failure.
|
|
59
|
+
*/
|
|
60
|
+
export declare function classifyProvisioningFailure(message: string): ProvisioningFailure | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The one-line failure the producer wrote, for quoting back verbatim.
|
|
63
|
+
* @param failure - a recognized failure.
|
|
64
|
+
* @returns the message text a `Win32Error` would have produced.
|
|
65
|
+
*/
|
|
66
|
+
export declare function failureLine(failure: ProvisioningFailure): string;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent bookkeeping: what this environment has already been told, and (in
|
|
3
|
+
* the optional fail-fast half) which calls have already been refused by it.
|
|
4
|
+
*
|
|
5
|
+
* The state is deliberately keyed by **agent**, not by session id string: the
|
|
6
|
+
* failing call carries `exec.agent`, one agent owns one session, and a WeakMap
|
|
7
|
+
* keyed by the agent object lets a finished session's state be collected.
|
|
8
|
+
*
|
|
9
|
+
* Two counters, two meanings — keeping them apart is what stops the plugin from
|
|
10
|
+
* feeding on itself:
|
|
11
|
+
*
|
|
12
|
+
* - `observations` counts *provisioning failures of this environment*, i.e.
|
|
13
|
+
* tool results the environment itself produced. A call this plugin denied is
|
|
14
|
+
* not one of them, even though its denial text quotes the Win32 line.
|
|
15
|
+
* - `failingKeys` holds the call identities (tool + canonical arguments) that
|
|
16
|
+
* have already failed this way. The fail-fast half may only refuse a call it
|
|
17
|
+
* has *watched fail* — never a call it merely recognizes as similar.
|
|
18
|
+
*
|
|
19
|
+
* `denials` is spent per *episode*: it is re-armed when a watched call finally
|
|
20
|
+
* succeeds (see `observeSuccess`), not carried for the whole session.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import type { ProvisioningFailure } from './signature.js';
|
|
25
|
+
/** Everything the plugin remembers about one agent. */
|
|
26
|
+
export interface AgentState {
|
|
27
|
+
/** Provisioning failures observed for this agent. */
|
|
28
|
+
observations: number;
|
|
29
|
+
/** The most recent failure, for the denial text. */
|
|
30
|
+
last: ProvisioningFailure;
|
|
31
|
+
/** Identity keys of the calls that failed this way. */
|
|
32
|
+
failingKeys: Set<string>;
|
|
33
|
+
/** Denials already spent. */
|
|
34
|
+
denials: number;
|
|
35
|
+
/** Whether the durable advisory has been delivered for this agent. */
|
|
36
|
+
advised: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Canonicalize a parsed argument value into a stable string.
|
|
40
|
+
*
|
|
41
|
+
* Key order in a JavaScript object is insertion order, so two structurally
|
|
42
|
+
* identical calls can serialize differently depending on how the model ordered
|
|
43
|
+
* its JSON. Sorting keys recursively gives the identity the fail-fast half
|
|
44
|
+
* needs; unsupported values (functions, symbols, cycles) fall back to a type
|
|
45
|
+
* tag rather than throwing, because a guard must never be the reason a call
|
|
46
|
+
* dies.
|
|
47
|
+
* @param value - the parsed tool arguments.
|
|
48
|
+
* @returns a stable string.
|
|
49
|
+
*/
|
|
50
|
+
export declare function canonicalize(value: unknown): string;
|
|
51
|
+
/**
|
|
52
|
+
* The identity of one call: its tool name plus its canonical arguments.
|
|
53
|
+
* @param name - the tool name.
|
|
54
|
+
* @param args - the parsed arguments.
|
|
55
|
+
* @returns the identity key.
|
|
56
|
+
*/
|
|
57
|
+
export declare function callKey(name: string, args: unknown): string;
|
|
58
|
+
/**
|
|
59
|
+
* Record one observed provisioning failure.
|
|
60
|
+
* @param state - the agent's current state, or undefined on first sight.
|
|
61
|
+
* @param failure - the recognized failure.
|
|
62
|
+
* @param key - the identity of the call that failed.
|
|
63
|
+
* @returns the updated state.
|
|
64
|
+
*/
|
|
65
|
+
export declare function observe(state: AgentState | undefined, failure: ProvisioningFailure, key: string): AgentState;
|
|
66
|
+
/**
|
|
67
|
+
* Record that a call carrying the same identity as a previously failing one
|
|
68
|
+
* succeeded. The environment worked at least once for that call, so the entry
|
|
69
|
+
* stops justifying a denial — and is dropped rather than kept, so a later
|
|
70
|
+
* failure re-earns it.
|
|
71
|
+
*
|
|
72
|
+
* The denial budget is re-armed at the same moment, and only then. Measured
|
|
73
|
+
* consequence: without it the budget is per agent for the whole session, which
|
|
74
|
+
* makes the entry above unobservable — past `maxDenials` this plugin refuses
|
|
75
|
+
* nothing ever again, so clearing the key would change no decision. With it the
|
|
76
|
+
* bound reads as "at most `maxDenials` refusals per episode of brokenness": an
|
|
77
|
+
* environment that breaks, is repaired and breaks again may be refused again,
|
|
78
|
+
* while a session can always make progress by spending the budget.
|
|
79
|
+
* @param state - the agent's current state.
|
|
80
|
+
* @param key - the identity of the call that just succeeded.
|
|
81
|
+
* @returns the updated state, unchanged when the key was not failing.
|
|
82
|
+
*/
|
|
83
|
+
export declare function observeSuccess(state: AgentState, key: string): AgentState;
|
|
84
|
+
/**
|
|
85
|
+
* Whether a call may be refused before dispatch.
|
|
86
|
+
*
|
|
87
|
+
* Both conditions are required: the environment has failed provisioning at
|
|
88
|
+
* least `enforceAfter` times, **and** this exact call is one this plugin watched
|
|
89
|
+
* fail. The second condition is what keeps the fail-fast half from blocking a
|
|
90
|
+
* workaround: a different command, or the same command under a different policy
|
|
91
|
+
* after the user changed configuration, has no key here.
|
|
92
|
+
* @param state - the agent's current state, or undefined.
|
|
93
|
+
* @param key - the identity of the call about to dispatch.
|
|
94
|
+
* @param enforceAfter - the configured threshold; 0 disables the half entirely.
|
|
95
|
+
* @param maxDenials - the configured denial budget.
|
|
96
|
+
* @returns whether to deny.
|
|
97
|
+
*/
|
|
98
|
+
export declare function shouldDeny(state: AgentState | undefined, key: string, enforceAfter: number, maxDenials: number): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Spend one denial.
|
|
101
|
+
* @param state - the agent's current state.
|
|
102
|
+
* @returns the updated state.
|
|
103
|
+
*/
|
|
104
|
+
export declare function recordDenial(state: AgentState): AgentState;
|
|
105
|
+
/**
|
|
106
|
+
* Mark the durable advisory as delivered.
|
|
107
|
+
* @param state - the agent's current state.
|
|
108
|
+
* @returns the updated state.
|
|
109
|
+
*/
|
|
110
|
+
export declare function recordAdvice(state: AgentState): AgentState;
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@argszero/cordis-plugin-sandbox-grant-advisor",
|
|
3
|
+
"description": "Turns the Windows sandbox's ACL provisioning failure into a diagnosis with a path forward. Three reports (#7538, #7622, #7646) describe one signature — every sandboxed command fails before it runs with `SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace>)` — and the error names neither the missing right nor a remedy, so the loop burns tokens and sessions escape into danger-full-access. The host-side grant is materialized lazily and caches nothing on the failure path, so the same failure repeats per command. This plugin observes the public `tools/post-execute` waterfall, classifies that signature (only the two `...NamedSecurityInfoW` operations, keeping the Win32 code and the producer's own detail verbatim), and attaches ONE durable user-role advisory per agent through `additionalContexts`: the missing right is WRITE_OWNER on the directory (an object right the caller can self-grant), not SeSecurityPrivilege and not elevation; the directory's current ACL is the discriminator; the fix is one unelevated `icacls` line. An optional, off-by-default `enforceAfter` refuses an identical call this plugin has watched fail, bounded by `maxDenials`. It never edits an ACL and never elevates, and it complements repeat-guard-escalation, which keys on call identity rather than on the environment signature.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./src/*": "./src/*",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib/**/*.js",
|
|
18
|
+
"lib/types/**/*.d.ts",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/argszero/cordis-plugin-sandbox-grant-advisor.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/argszero/cordis-plugin-sandbox-grant-advisor#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/argszero/cordis-plugin-sandbox-grant-advisor/issues"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"cordis",
|
|
34
|
+
"deepseek-harness",
|
|
35
|
+
"dsh",
|
|
36
|
+
"plugin",
|
|
37
|
+
"sandbox",
|
|
38
|
+
"windows",
|
|
39
|
+
"acl",
|
|
40
|
+
"win32",
|
|
41
|
+
"diagnostics",
|
|
42
|
+
"guard"
|
|
43
|
+
],
|
|
44
|
+
"dsh": {
|
|
45
|
+
"bundle": {
|
|
46
|
+
"patch": "./cordis.patch.yml"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
51
|
+
"@deepseek-ai/dsh-agent": ">=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0",
|
|
52
|
+
"@deepseek-ai/dsh-llm": ">=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0",
|
|
53
|
+
"@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
57
|
+
"@deepseek-ai/dsh-agent": "0.1.7-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-llm": "0.1.7-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.7-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-tools": "0.1.7-rc.1",
|
|
61
|
+
"@types/node": "^22.10.2",
|
|
62
|
+
"semver": "^7.6.0",
|
|
63
|
+
"typescript": "^5.5.0"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "tsc",
|
|
67
|
+
"pretest": "tsc",
|
|
68
|
+
"test": "node --test \"test/*.spec.mjs\"",
|
|
69
|
+
"test:probe-lines": "node scripts/probe-lines.mjs",
|
|
70
|
+
"prepublishOnly": "tsc"
|
|
71
|
+
},
|
|
72
|
+
"engines": {
|
|
73
|
+
"node": "^22.19 || >=24"
|
|
74
|
+
}
|
|
75
|
+
}
|