@argszero/cordis-plugin-sandbox-grant-advisor 0.1.0 → 0.2.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 +205 -76
- package/cordis.patch.yml +18 -0
- package/lib/advice.js +130 -18
- package/lib/index.js +208 -51
- package/lib/mode.js +135 -0
- package/lib/signature.js +56 -5
- package/lib/state.js +121 -44
- package/lib/types/advice.d.ts +82 -20
- package/lib/types/index.d.ts +81 -36
- package/lib/types/mode.d.ts +87 -0
- package/lib/types/signature.d.ts +68 -5
- package/lib/types/state.d.ts +84 -34
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
1
|
# @argszero/cordis-plugin-sandbox-grant-advisor
|
|
2
2
|
|
|
3
|
-
Turns a
|
|
4
|
-
diagnosis the model — and the user reading the transcript — can act on.
|
|
3
|
+
Turns a sandbox environment failure that has **no path forward** into a
|
|
4
|
+
diagnosis the model — and the user reading the transcript — can act on. Two
|
|
5
|
+
signatures, one mechanism:
|
|
5
6
|
|
|
6
7
|
```
|
|
7
|
-
SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
|
|
8
|
+
SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws) # Windows workspace ACL
|
|
9
|
+
PTY shell exited during startup # persistent shell × confining mode
|
|
8
10
|
```
|
|
9
11
|
|
|
12
|
+
**This plugin is the stopgap for "the error does not name the outstanding
|
|
13
|
+
condition".** It repairs nothing: no ACL is written, no privilege is requested,
|
|
14
|
+
nothing is elevated, no preset is installed and no mode is changed.
|
|
15
|
+
|
|
16
|
+
## The two failures it recognizes
|
|
17
|
+
|
|
18
|
+
Both are recognized on the public **`tools/post-execute`** waterfall
|
|
19
|
+
(`@deepseek-ai/dsh-tools`) — the one seam that has all three of: the failure text
|
|
20
|
+
(providers propagate their error unchanged and the tool pipeline settles it as an
|
|
21
|
+
`isError` result), an agent identity to attribute it to (`exec.agent`), and a
|
|
22
|
+
channel that speaks to the model in the same step (`PostToolDecision`'s
|
|
23
|
+
`additionalContexts`, which the agent loop turns into a durable user-role message
|
|
24
|
+
— `packages/core/agent-loop/src/tool-calls.ts`).
|
|
25
|
+
|
|
26
|
+
That seam, not `ctx.sandbox.confine`: `confine(argv, policy, signal)` sees the
|
|
27
|
+
confinement failure too, but its signature carries no agent, so a wrapper could
|
|
28
|
+
detect the condition and never deliver a word about it to the session that is
|
|
29
|
+
stuck.
|
|
30
|
+
|
|
31
|
+
### 1. Workspace provisioning — the Windows ACL failure (`acl-provisioning`)
|
|
32
|
+
|
|
10
33
|
Three reports describe this exact line: [discussion #7538], [discussion #7622],
|
|
11
34
|
[discussion #7646]. In each one every sandboxed command fails the same way,
|
|
12
35
|
before it runs, and the error names neither the missing right nor a remedy.
|
|
13
36
|
|
|
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
37
|
The Windows backend provisions a workspace by writing the directory's DACL and
|
|
21
38
|
its mandatory-integrity label in **one** `SetNamedSecurityInfoW` call
|
|
22
|
-
(`packages/sandbox/sandbox-windows-acl/src/acl.ts
|
|
39
|
+
(`packages/sandbox/sandbox-windows-acl/src/acl.ts`):
|
|
23
40
|
|
|
24
41
|
```ts
|
|
25
42
|
if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`)
|
|
26
43
|
```
|
|
27
44
|
|
|
28
|
-
|
|
45
|
+
Two consequences follow from that one line:
|
|
29
46
|
|
|
30
47
|
1. **The label lives in the SACL, and its half is what gets refused.** The
|
|
31
48
|
owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`, so the
|
|
@@ -45,53 +62,127 @@ cached when it throws** — so the same failure repeats per command (850 calls
|
|
|
45
62
|
across 39 sessions in #7622; 52,588 output tokens with no output in #7538),
|
|
46
63
|
which is why the loop cannot separate it from ordinary command noise.
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
65
|
+
On the first recognized failure, the result is enriched with:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.
|
|
69
|
+
|
|
70
|
+
What was reported:
|
|
71
|
+
SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
|
|
72
|
+
|
|
73
|
+
Why it is refused while the directory looks writable: that call is a MERGED write ...
|
|
74
|
+
... the label half additionally needs WRITE_OWNER on the directory. ...
|
|
75
|
+
|
|
76
|
+
Confirm the cause (unelevated) — `icacls` is a normal user command:
|
|
77
|
+
icacls "D:\ws"
|
|
78
|
+
|
|
79
|
+
Fix it (unelevated, one line) and then run the command again:
|
|
80
|
+
PowerShell: icacls "D:\ws" /grant "$env:USERNAME:(OI)(CI)F"
|
|
81
|
+
cmd: icacls "D:\ws" /grant "%USERNAME%:(OI)(CI)F"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Persistent shell startup (`pty-startup`)
|
|
85
|
+
|
|
86
|
+
[Discussion #7638] reports the second shape: with the **`minimal` preset** on
|
|
87
|
+
Windows, and under a **confining** sandbox mode (`workspace-write` / `read-only`,
|
|
88
|
+
not `danger-full-access`), **every** shell call dies instantly with
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
PTY shell exited during startup
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The terminal backend spawns the shell through the sandbox
|
|
95
|
+
(`packages/terminal/terminal-bash/src/index.ts`; the throw is in
|
|
96
|
+
`src/session.ts` and `src/index.ts`, both on the same `waitReason ===
|
|
97
|
+
'session_exit'` branch), and there the pseudo-console cannot be created at all,
|
|
98
|
+
so the child exits before its first prompt. Retrying never helps; the message
|
|
99
|
+
points at no cause.
|
|
100
|
+
|
|
101
|
+
The reporter's own three-arm control makes the sandbox mode the discriminator:
|
|
102
|
+
minimal × confining fails, minimal × `danger-full-access` succeeds, `standard`
|
|
103
|
+
(one-shot shell) × confining succeeds. That is why the advisory is only ever
|
|
104
|
+
built with the **resolved** mode the failing call actually ran under — from
|
|
105
|
+
`ctx.sandboxPolicy.resolve({ session })`, the same resolver the terminal layer
|
|
106
|
+
calls before spawning, with the same session.
|
|
107
|
+
|
|
108
|
+
The advisory that follows is addressed to **two different readers**:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Persistent shell failed to start — command execution is unavailable in this session, and retrying cannot fix it.
|
|
112
|
+
|
|
113
|
+
What was reported:
|
|
114
|
+
PTY shell exited during startup
|
|
115
|
+
|
|
116
|
+
The `bash` tool is a PERSISTENT PTY session (a shell that stays alive between calls), and this session's sandbox mode is
|
|
117
|
+
`workspace-write` — not `danger-full-access`. A confining mode spawns the shell through the sandbox, and there the
|
|
118
|
+
terminal backend cannot create the pseudo-console at all, so the child exits before its first prompt. ...
|
|
119
|
+
|
|
120
|
+
Do NOT retry, and do not look for a command that fixes it: every attempt will fail identically, and there is no
|
|
121
|
+
shell to run a command in. Use your file read/write tools instead, and hand the choice below to the user.
|
|
122
|
+
|
|
123
|
+
What unblocks the session — the user's decision, not the model's:
|
|
124
|
+
1. switch the agent preset to `standard`, whose shell tool is a one-shot subprocess (no PTY) and works
|
|
125
|
+
under the sandbox; or
|
|
126
|
+
2. override the `preset-minimal` row in your profile patch — `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, or
|
|
127
|
+
`$DSH_HOME/cordis.patch.yml` for every profile — replacing its `persistent-shell` group with
|
|
128
|
+
`@deepseek-ai/dsh-tool-pwsh` (a one-shot subprocess, no PTY); the patch layer is yours, so an upgrade
|
|
129
|
+
will not overwrite it; or
|
|
130
|
+
3. run the session with `danger-full-access`, which drops the very confinement the sandbox exists to give.
|
|
131
|
+
Prefer 1 or 2.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The model's instruction is to **stop** — not to run a command (there is no shell
|
|
135
|
+
to run it in) and not to call a fallback shell tool (`minimal` mounts exactly
|
|
136
|
+
**one** platform-selected persistent shell and **no** one-shot shell, by design:
|
|
137
|
+
`.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md`).
|
|
138
|
+
Naming a tool the failing composition does not mount would be a wrong remedy,
|
|
139
|
+
which is the main risk this family's text is written to avoid.
|
|
140
|
+
|
|
141
|
+
The remedy is a **patch layer**, not a directory. The pre-declarative
|
|
142
|
+
`$DSH_HOME/.agent-presets/<id>/` preset folder is a plausible-looking trap: it
|
|
143
|
+
still reads as the natural place to put a preset, and nothing in the harness
|
|
144
|
+
reads it any more (`@deepseek-ai/dsh-agent-preset-registry`: the registry
|
|
145
|
+
"neither scans directories nor accepts preset paths"). Preset changes are
|
|
146
|
+
`@deepseek-ai/dsh-agent-preset` rows — an `insert` for a new one, a patch keyed
|
|
147
|
+
by row id (`preset-minimal`) for a change to a shipped one. A test arm asserts
|
|
148
|
+
the advisory never names the dead directory.
|
|
149
|
+
|
|
150
|
+
## What it does with a recognized failure
|
|
151
|
+
|
|
152
|
+
1. **One durable advisory per agent, per family.** An agent that hits both
|
|
153
|
+
families is told about **both**, once each. The notice carries its own
|
|
154
|
+
producer-owned `source.kind` (`sandbox-grant-advisor`) — not the retired
|
|
155
|
+
`plugin` wrapper, which the current session format refuses — and a bounded
|
|
156
|
+
one-line `summary` for the transcript row. The host log gets one matching
|
|
157
|
+
`warn` line, so the fact survives outside the transcript too.
|
|
158
|
+
2. **A disclosure when it withholds.** The PTY advisory is only sent when the
|
|
159
|
+
resolved mode actually confines. If the mode is `danger-full-access`, or
|
|
160
|
+
cannot be resolved at all (no `sandboxPolicy` service mounted, no agent
|
|
161
|
+
session, a resolver that throws), the failure is left exactly as it was
|
|
162
|
+
**and the host log says so once**. Silence alone would make "the sandbox is
|
|
163
|
+
not the cause" and "this plugin could not tell" indistinguishable from the
|
|
164
|
+
outside. Withholding is never a guess: an unresolvable mode is *not* an
|
|
165
|
+
invitation to fall back to the deployment default.
|
|
166
|
+
3. **An optional, bounded fail-fast half** (`enforceAfter`, default **0** =
|
|
167
|
+
off) — **ACL family only**. It refuses a call **before dispatch**
|
|
168
|
+
(`tools/pre-execute`) only when both hold: the environment has failed
|
|
169
|
+
provisioning at least `enforceAfter` times, **and** this exact call (tool +
|
|
170
|
+
canonical arguments) is one this plugin watched fail. The budget is
|
|
171
|
+
`maxDenials` (default 2), after which the call proceeds again. The budget is
|
|
172
|
+
per **episode of brokenness**: a call that finally succeeds stops being a
|
|
173
|
+
denial target and re-arms it, so an environment that breaks twice can be
|
|
174
|
+
refused twice — while a session can always make progress by spending the
|
|
93
175
|
budget it has.
|
|
94
176
|
|
|
177
|
+
**Why the blocking half does not extend to the PTY family** (it is
|
|
178
|
+
ACL-only by construction, in the parameter type): the ACL remedy is a command
|
|
179
|
+
the user can run *while the session continues*, so refusing further identical
|
|
180
|
+
calls cannot make the session unfinishable — spending the budget always lets
|
|
181
|
+
the call through, and a repaired environment is discovered by exactly that.
|
|
182
|
+
The PTY remedy is a preset swap, which happens **between** sessions;
|
|
183
|
+
refusing calls there could only pad a session that is already unable to do
|
|
184
|
+
the thing being refused.
|
|
185
|
+
|
|
95
186
|
## Install
|
|
96
187
|
|
|
97
188
|
```sh
|
|
@@ -124,6 +215,11 @@ Mount it by adding the patch to your profile, or apply the shipped
|
|
|
124
215
|
enforceAfter: 3
|
|
125
216
|
```
|
|
126
217
|
|
|
218
|
+
`include` / `exclude` narrow **both** families: an untracked call is
|
|
219
|
+
transparent to the plugin entirely, so a watched-out shell call produces no PTY
|
|
220
|
+
advisory (and no withholding note either — the configuration said "not our
|
|
221
|
+
story", which is different from "we could not tell").
|
|
222
|
+
|
|
127
223
|
## What it deliberately refuses to explain
|
|
128
224
|
|
|
129
225
|
Recognition is narrow, because a classifier that names the wrong cause is worse
|
|
@@ -135,42 +231,59 @@ than one that stays silent.
|
|
|
135
231
|
problem, and neither are the `LocalFree`, `LockFileEx`,
|
|
136
232
|
`SetConsoleCtrlHandler` or `SetEnvironmentVariableW` failures thrown by the
|
|
137
233
|
same package.
|
|
234
|
+
- **The PTY family is matched on a whole line, not a substring.** The producer's
|
|
235
|
+
message *is* the sentence (`PTY shell exited during startup`) with no detail
|
|
236
|
+
field at all, so any longer line that merely contains it is something
|
|
237
|
+
**quoting** it — a transcript, a log a failing command printed, a pasted issue
|
|
238
|
+
body — and the harness is not the producer.
|
|
239
|
+
- **The sibling throw is not classified.** `PTY shell did not reach readiness
|
|
240
|
+
before startup timeout` means the shell started and then did not reach a
|
|
241
|
+
prompt: a different cause space (a slow or blocked shell) with a different
|
|
242
|
+
remedy.
|
|
138
243
|
- **The Win32 code is kept, not flattened.** `ERROR_ACCESS_DENIED` (5) is the
|
|
139
244
|
case the documented prerequisite explains; another code gets a different
|
|
140
245
|
paragraph that says so instead of borrowing the same sentence.
|
|
141
246
|
- **A successful command whose *output* contains the line is not a failure.**
|
|
142
247
|
The gate is the result's error state, not the presence of the text — reading a
|
|
143
248
|
log file that quotes the error must not trigger advice.
|
|
144
|
-
- **Only one advisory per agent.** The environment is explained
|
|
145
|
-
it per failed command would be noise competing with the
|
|
249
|
+
- **Only one advisory per agent, per family.** The environment is explained
|
|
250
|
+
once; repeating it per failed command would be noise competing with the
|
|
251
|
+
failure itself.
|
|
146
252
|
|
|
147
253
|
## Honest boundaries
|
|
148
254
|
|
|
149
|
-
- **The Windows path itself cannot be witnessed on macOS**, where this plugin
|
|
150
|
-
built. What the test suite proves is the decision layer — classification
|
|
151
|
-
once-per-agent rule, the
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
`
|
|
156
|
-
|
|
255
|
+
- **The Windows path itself cannot be witnessed on macOS**, where this plugin
|
|
256
|
+
was built. What the test suite proves is the decision layer — classification
|
|
257
|
+
of both families, the once-per-agent-per-family rule, the sandbox-mode gate
|
|
258
|
+
and its fail-closed behaviour, the fail-fast budget and its self-feeding
|
|
259
|
+
guard, and the wiring to a real cordis `Context` and the real `ToolRuntime` —
|
|
260
|
+
driven by fixtures that throw the producers' exact error shapes
|
|
261
|
+
(`Win32Error`, `packages/subprocess/win32-process/src/errors.ts`; the
|
|
262
|
+
terminal throws, `packages/terminal/terminal-bash/src/{index,session}.ts`). It
|
|
263
|
+
does **not** prove that `icacls ... :(OI)(CI)F` fixes a given machine, nor that
|
|
264
|
+
a given Windows host reproduces the PTY startup failure; those are the user's
|
|
265
|
+
one-line experiment and the reporter's own control, and both advisories say
|
|
266
|
+
where they stop.
|
|
157
267
|
- **It repairs nothing and elevates nothing.** If the directory really is
|
|
158
|
-
Full-control for the caller, the remaining hypothesis is
|
|
159
|
-
— i.e. the backend's documented prerequisite would be
|
|
160
|
-
upstream question; the advisory states the discriminator
|
|
161
|
-
the answer.
|
|
162
|
-
- **Delivery to the model is the agent loop's.** `additionalContexts` are
|
|
163
|
-
on the settled result here and appended as durable user-role events by
|
|
268
|
+
Full-control for the caller, the remaining ACL hypothesis is
|
|
269
|
+
`SeSecurityPrivilege` — i.e. the backend's documented prerequisite would be
|
|
270
|
+
wrong. That is an upstream question; the advisory states the discriminator
|
|
271
|
+
rather than assuming the answer.
|
|
272
|
+
- **Delivery to the model is the agent loop's.** `additionalContexts` are
|
|
273
|
+
ferried on the settled result here and appended as durable user-role events by
|
|
164
274
|
`agent-loop`; a direct `ctx.tools.execute()` caller with no agent gets no
|
|
165
275
|
advisory (and no agent to explain anything to).
|
|
166
276
|
- **It complements `@argszero/cordis-plugin-repeat-guard-escalation`, it does not
|
|
167
277
|
replace it.** That guard keys on **call identity** (identical arguments
|
|
168
278
|
retried); this one keys on the **environment signature**, which is how several
|
|
169
279
|
*different* commands share one cause. Mounting both is sensible.
|
|
170
|
-
- **The real fix is upstream.**
|
|
171
|
-
`
|
|
172
|
-
false, so the diagnostic that turns
|
|
173
|
-
|
|
280
|
+
- **The real fix is upstream, in both families.** For the ACL failure,
|
|
281
|
+
`grantWrite` already computes `hasExactGrant` / `hasExactDeny` /
|
|
282
|
+
`hasExactLabel` and discards which one was false, so the diagnostic that turns
|
|
283
|
+
a 52-minute detour into one line belongs at that site. For the PTY failure,
|
|
284
|
+
the startup path should either report "this sandbox mode is incompatible with
|
|
285
|
+
the PTY backend" or fall back to a one-shot shell. This plugin is the stopgap
|
|
286
|
+
for both.
|
|
174
287
|
|
|
175
288
|
## Compatibility
|
|
176
289
|
|
|
@@ -188,6 +301,11 @@ Harness peers — all three carry the **same** range, quoted in full on purpose
|
|
|
188
301
|
that reason.
|
|
189
302
|
- `@deepseek-ai/cordis@^4.0.2`.
|
|
190
303
|
|
|
304
|
+
`@deepseek-ai/dsh-sandbox-policy` is **not** a peer: the PTY family's mode
|
|
305
|
+
lookup is a guarded, structural one (`ctx.get('sandboxPolicy')`) precisely so a
|
|
306
|
+
composition that does not mount the service degrades to silence instead of
|
|
307
|
+
failing to load. See `src/mode.ts`.
|
|
308
|
+
|
|
191
309
|
Probed at the newest build of every line the range admits — `0.1.2-rc.1`,
|
|
192
310
|
`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
311
|
report ran) — with `npm run test:probe-lines`, which derives those builds from
|
|
@@ -200,6 +318,7 @@ left claimed.
|
|
|
200
318
|
```sh
|
|
201
319
|
npm install
|
|
202
320
|
npm test # tsc, then the suite (real cordis + real ToolRuntime)
|
|
321
|
+
npm run test:inject # defect injection: mutate the source, rebuild, require the suite to go red
|
|
203
322
|
npm run test:probe-lines # install the newest build of each admitted line and run the suite against it
|
|
204
323
|
npm run test:probe-lines -- 0.1.7-rc.1 # one line only
|
|
205
324
|
```
|
|
@@ -207,6 +326,16 @@ npm run test:probe-lines -- 0.1.7-rc.1 # one line only
|
|
|
207
326
|
The suite is mostly control arms: a guard that explains the wrong failure, or
|
|
208
327
|
refuses a call that would have worked, is worse than one that stays silent.
|
|
209
328
|
|
|
329
|
+
`test:inject` exists because an arm nobody has seen fail proves nothing. It
|
|
330
|
+
mutates the decision layer one defect at a time — the mode gate removed, the
|
|
331
|
+
PTY message matched as a substring, the preset remedy pointed back at the dead
|
|
332
|
+
legacy directory, the two families collapsed into one bookkeeping slot, the
|
|
333
|
+
advisory delivered per call instead of per agent — and requires that specific
|
|
334
|
+
arms fail. It reports `SILENT ARMS: none` when every arm bites, restores the
|
|
335
|
+
source in a `finally`, and prints `EQUIVALENT` (with the reason) for a mutation
|
|
336
|
+
the current runtime cannot distinguish rather than counting it as a pass.
|
|
337
|
+
|
|
210
338
|
[discussion #7538]: https://github.com/deepseek-ai/deepseek-harness/discussions/7538
|
|
211
339
|
[discussion #7622]: https://github.com/deepseek-ai/deepseek-harness/discussions/7622
|
|
212
340
|
[discussion #7646]: https://github.com/deepseek-ai/deepseek-harness/discussions/7646
|
|
341
|
+
[discussion #7638]: https://github.com/deepseek-ai/deepseek-harness/discussions/7638
|
package/cordis.patch.yml
CHANGED
|
@@ -45,6 +45,24 @@
|
|
|
45
45
|
# is off by default on purpose: it may only refuse a call it has watched fail in
|
|
46
46
|
# this environment, and it is bounded by `maxDenials`, because a plugin that can
|
|
47
47
|
# stop command execution must never be the reason a session cannot finish.
|
|
48
|
+
#
|
|
49
|
+
# A SECOND family is recognized on the same seam (#7638): with the `minimal`
|
|
50
|
+
# preset and a confining sandbox mode, every shell call dies instantly with
|
|
51
|
+
#
|
|
52
|
+
# PTY shell exited during startup
|
|
53
|
+
#
|
|
54
|
+
# The terminal backend cannot create the pseudo-console inside the sandbox, so
|
|
55
|
+
# the child exits before its first prompt; retrying never helps and `minimal`
|
|
56
|
+
# mounts no fallback shell tool. That advisory states the resolved mode, tells
|
|
57
|
+
# the model to STOP rather than retry, and hands the user a preset choice — it
|
|
58
|
+
# never names a command to run (there is no shell to run it in) and never names
|
|
59
|
+
# a shell tool the failing composition does not mount. It is sent only when the
|
|
60
|
+
# mode the call actually ran under confines; otherwise the failure is left
|
|
61
|
+
# untouched and the host log says so once (silence alone would read as "the
|
|
62
|
+
# sandbox is not the cause", which this plugin cannot claim). The blocking half
|
|
63
|
+
# above deliberately does NOT cover this family: its remedy is a patch-layer
|
|
64
|
+
# change the user makes between sessions, not a command that repairs the
|
|
65
|
+
# running one.
|
|
48
66
|
|
|
49
67
|
- insert:
|
|
50
68
|
- id: sandbox-grant-advisor
|
package/lib/advice.js
CHANGED
|
@@ -1,29 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the model — and through it the user — is told about a
|
|
3
|
-
* failure, and what is deliberately withheld.
|
|
2
|
+
* What the model — and through it the user — is told about a recognized
|
|
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.
|
|
6
|
+
* by a test, one family at a time. The two families are shaped by the same two
|
|
7
|
+
* questions, and they answer them differently:
|
|
7
8
|
*
|
|
8
|
-
* - **
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
9
|
+
* - **The ACL failure** (`acl-provisioning`) *is* fixable by the caller, so its
|
|
10
|
+
* advice names the right the caller is missing and gives the command.
|
|
11
|
+
* The reported failures are `ERROR_ACCESS_DENIED` from a *merged* DACL + SACL
|
|
12
|
+
* write; the missing right is `WRITE_OWNER` on the directory — an object right
|
|
13
|
+
* the caller can grant itself with `icacls`, unelevated. It is **not**
|
|
14
|
+
* `SeSecurityPrivilege`, the token privilege the reports naturally reach for;
|
|
15
|
+
* `whoami /priv` cannot show the difference, and elevation is the wrong lever.
|
|
16
|
+
* - **The persistent-shell failure** (`pty-startup`) is *not* fixable by the
|
|
17
|
+
* caller — least of all by the model, which has no shell to run anything in.
|
|
18
|
+
* So its advice says so and stops: the remedy is a user-side preset choice,
|
|
19
|
+
* and the model's instruction is to stop retrying and use its file tools.
|
|
20
|
+
* Handing the model a command here would be advice to run something that
|
|
21
|
+
* cannot run, and naming a one-shot shell tool would be advice to call a tool
|
|
22
|
+
* the failing composition does not mount.
|
|
23
|
+
*
|
|
24
|
+
* Both give a **discriminator, not just a remedy**: applying a fix without
|
|
25
|
+
* confirming the cause teaches nothing when the fix does not work. For the ACL
|
|
26
|
+
* family that is `icacls <dir>`, looking for an ACE that names the caller's own
|
|
27
|
+
* SID and grants `(F)` — which separates "Modify-only directory" from "the
|
|
28
|
+
* documented prerequisite is wrong", the open question upstream. For the PTY
|
|
29
|
+
* family it is the **effective sandbox mode**, which is why that advisory is
|
|
30
|
+
* only ever built with the mode the call actually ran under.
|
|
19
31
|
*
|
|
20
32
|
* @module
|
|
21
33
|
*/
|
|
22
34
|
import { failureLine } from './signature.js';
|
|
23
|
-
/** The upstream threads
|
|
24
|
-
export const
|
|
35
|
+
/** The upstream threads the ACL advisory is a stopgap for. */
|
|
36
|
+
export const ACL_DISCUSSIONS = '#7538 / #7622 / #7646';
|
|
37
|
+
/** The upstream thread the persistent-shell advisory is a stopgap for. */
|
|
38
|
+
export const PTY_DISCUSSIONS = '#7638';
|
|
25
39
|
/** The documented prerequisite, quoted from the backend's README. */
|
|
26
40
|
export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
|
|
41
|
+
/**
|
|
42
|
+
* Where a user's own preset changes actually live.
|
|
43
|
+
*
|
|
44
|
+
* This is deliberately **not** the legacy `$DSH_HOME/.agent-presets/<id>/`
|
|
45
|
+
* directory: that shape predates declarative presets and **nothing reads it any
|
|
46
|
+
* more** (the registry "neither scans directories nor accepts preset paths").
|
|
47
|
+
* A preset is a `@deepseek-ai/dsh-agent-preset` row, and changing one means
|
|
48
|
+
* overriding or inserting that row in a patch layer, which is what this path
|
|
49
|
+
* names. Advising a folder the harness stopped reading would be the same defect
|
|
50
|
+
* this plugin exists to answer — a remedy that does not work, delivered
|
|
51
|
+
* confidently.
|
|
52
|
+
*/
|
|
53
|
+
export const PROFILE_PATCH = '$DSH_HOME/profiles/<profile>/cordis.patch.yml';
|
|
54
|
+
/** The machine-wide patch layer, for a change that should hold in every profile. */
|
|
55
|
+
export const GLOBAL_PATCH = '$DSH_HOME/cordis.patch.yml';
|
|
56
|
+
/** The row id the shipped `minimal` preset is declared under. */
|
|
57
|
+
export const MINIMAL_PRESET_ROW = 'preset-minimal';
|
|
58
|
+
/** The one-shot shell tool the `standard` preset mounts on Windows. */
|
|
59
|
+
export const ONE_SHOT_SHELL = '@deepseek-ai/dsh-tool-pwsh';
|
|
27
60
|
/** Placeholder the user replaces with the directory the error named. */
|
|
28
61
|
const PLACEHOLDER = '<the directory from the error line above>';
|
|
29
62
|
/**
|
|
@@ -60,13 +93,33 @@ function diagnosis(failure) {
|
|
|
60
93
|
}
|
|
61
94
|
/**
|
|
62
95
|
* Build the advisory attached to the failing tool result.
|
|
96
|
+
*
|
|
97
|
+
* The family decides everything: one function so a caller does not have to
|
|
98
|
+
* remember which family needs which fact, and so the mode requirement of the
|
|
99
|
+
* PTY family is enforced by construction rather than by convention.
|
|
100
|
+
* @param failure - the recognized failure.
|
|
101
|
+
* @param context - what the caller knows about the failing call.
|
|
102
|
+
* @returns the user-role notice text, with any remedy ready to paste.
|
|
103
|
+
* @throws when a PTY failure is advised without its resolved sandbox mode.
|
|
104
|
+
*/
|
|
105
|
+
export function advisoryText(failure, context = {}) {
|
|
106
|
+
if (failure.family === 'pty-startup') {
|
|
107
|
+
if (context.mode === undefined) {
|
|
108
|
+
throw new Error('sandbox-grant-advisor: the persistent-shell advisory requires the resolved sandbox mode');
|
|
109
|
+
}
|
|
110
|
+
return ptyAdvisory(failure, context.mode, context.tool, context.href);
|
|
111
|
+
}
|
|
112
|
+
return aclAdvisory(failure, context.href);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build the advisory for a workspace-provisioning failure.
|
|
63
116
|
* @param failure - the recognized failure.
|
|
64
117
|
* @param href - optional URL shown for the upstream thread.
|
|
65
118
|
* @returns the user-role notice text, with the fix commands ready to paste.
|
|
66
119
|
*/
|
|
67
|
-
|
|
120
|
+
function aclAdvisory(failure, href) {
|
|
68
121
|
const path = failure.path ?? PLACEHOLDER;
|
|
69
|
-
const where = href === undefined ? `tracked upstream (discussions ${
|
|
122
|
+
const where = href === undefined ? `tracked upstream (discussions ${ACL_DISCUSSIONS})` : `tracked upstream: ${href}`;
|
|
70
123
|
return [
|
|
71
124
|
'Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.',
|
|
72
125
|
'',
|
|
@@ -90,8 +143,67 @@ export function advisoryText(failure, href) {
|
|
|
90
143
|
'Your file read/write tools still work; only sandboxed command execution is blocked.',
|
|
91
144
|
].join('\n');
|
|
92
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Build the advisory for a persistent-shell startup failure.
|
|
148
|
+
*
|
|
149
|
+
* The one thing this text must never do is hand the model a command to run:
|
|
150
|
+
* there is no shell to run it in. That is why the remedy is addressed to the
|
|
151
|
+
* user (preset choice), while the model's instruction is to stop — the
|
|
152
|
+
* alternative, naming a one-shot shell tool, would be advice to call a tool the
|
|
153
|
+
* failing composition does not mount (`minimal` mounts exactly one platform
|
|
154
|
+
* shell, the persistent PTY: the design note
|
|
155
|
+
* `.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md`).
|
|
156
|
+
* @param failure - the recognized failure.
|
|
157
|
+
* @param mode - the resolved sandbox mode the failing call ran under.
|
|
158
|
+
* @param tool - the tool whose call failed, when the caller knows it.
|
|
159
|
+
* @param href - optional URL shown for the upstream thread.
|
|
160
|
+
* @returns the user-role notice text.
|
|
161
|
+
*/
|
|
162
|
+
function ptyAdvisory(failure, mode, tool, href) {
|
|
163
|
+
const where = href === undefined ? `tracked upstream (discussion ${PTY_DISCUSSIONS})` : `tracked upstream: ${href}`;
|
|
164
|
+
const call = tool === undefined ? 'This tool' : `The \`${tool}\` tool`;
|
|
165
|
+
return [
|
|
166
|
+
'Persistent shell failed to start — command execution is unavailable in this session, and retrying cannot fix it.',
|
|
167
|
+
'',
|
|
168
|
+
'What was reported:',
|
|
169
|
+
` ${failureLine(failure)}`,
|
|
170
|
+
'',
|
|
171
|
+
`${call} is a PERSISTENT PTY session (a shell that stays alive between calls), and this session's sandbox mode is`,
|
|
172
|
+
`\`${mode}\` — not \`danger-full-access\`. A confining mode spawns the shell through the sandbox, and there the`,
|
|
173
|
+
'terminal backend cannot create the pseudo-console at all, so the child exits before its first prompt. The same',
|
|
174
|
+
'shell works under `danger-full-access`, and the one-shot shell tool works under the same confining mode:',
|
|
175
|
+
'persistent PTY × confining sandbox is the combination that fails.',
|
|
176
|
+
'',
|
|
177
|
+
'Do NOT retry, and do not look for a command that fixes it: every attempt will fail identically, and there is no',
|
|
178
|
+
'shell to run a command in. Use your file read/write tools instead, and hand the choice below to the user.',
|
|
179
|
+
'',
|
|
180
|
+
'What unblocks the session — the user\'s decision, not the model\'s:',
|
|
181
|
+
' 1. switch the agent preset to `standard`, whose shell tool is a one-shot subprocess (no PTY) and works',
|
|
182
|
+
' under the sandbox; or',
|
|
183
|
+
` 2. override the \`${MINIMAL_PRESET_ROW}\` row in your profile patch — \`${PROFILE_PATCH}\`, or`,
|
|
184
|
+
` \`${GLOBAL_PATCH}\` for every profile — replacing its \`persistent-shell\` group with`,
|
|
185
|
+
` \`${ONE_SHOT_SHELL}\` (a one-shot subprocess, no PTY); the patch layer is yours, so an upgrade`,
|
|
186
|
+
' will not overwrite it; or',
|
|
187
|
+
' 3. run the session with `danger-full-access`, which drops the very confinement the sandbox exists to give.',
|
|
188
|
+
' Prefer 1 or 2.',
|
|
189
|
+
'',
|
|
190
|
+
'How to read this: the failure names no cause and points at no remedy, so the diagnosis is delivered here instead.',
|
|
191
|
+
'This is a stopgap, ' + where + '. Unless the mode is `danger-full-access`, this plugin stays silent, because a',
|
|
192
|
+
'shell can fail to start for other reasons and a confident wrong cause is worse than no answer.',
|
|
193
|
+
].join('\n');
|
|
194
|
+
}
|
|
93
195
|
/**
|
|
94
196
|
* Build the pre-dispatch denial for the optional fail-fast half.
|
|
197
|
+
*
|
|
198
|
+
* The blocking half is deliberately **ACL-only**, and this function's parameter
|
|
199
|
+
* type is where that is enforced. The PTY family gets an advisory and nothing
|
|
200
|
+
* else, for a reason that is about the remedy rather than about the failure:
|
|
201
|
+
* the ACL remedy is a command the user can run *while the session continues*,
|
|
202
|
+
* so refusing further identical calls cannot make the session unfinishable —
|
|
203
|
+
* spending the budget always lets the call through, and a repaired environment
|
|
204
|
+
* is discovered by exactly that. The PTY remedy is a preset swap, which happens
|
|
205
|
+
* between sessions; refusing calls could only pad a session that is already
|
|
206
|
+
* unable to do the thing being refused.
|
|
95
207
|
* @param failure - the recognized failure.
|
|
96
208
|
* @param observed - how many provisioning failures this agent has produced.
|
|
97
209
|
* @param denial - this denial's 1-based ordinal.
|