@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 CHANGED
@@ -1,31 +1,48 @@
1
1
  # @argszero/cordis-plugin-sandbox-grant-advisor
2
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.
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
- ). Two consequences follow from that one line:
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
- ## 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
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 once; repeating
145
- it per failed command would be noise competing with the failure itself.
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 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.
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 `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
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.** `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.
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 provisioning
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. Two properties matter more than the wording:
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
- * - **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.
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 this advisory is a stopgap for. */
24
- export const DISCUSSIONS = '#7538 / #7622 / #7646';
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
- export function advisoryText(failure, href) {
120
+ function aclAdvisory(failure, href) {
68
121
  const path = failure.path ?? PLACEHOLDER;
69
- const where = href === undefined ? `tracked upstream (discussions ${DISCUSSIONS})` : `tracked upstream: ${href}`;
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.