@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/lib/index.js
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `sandbox-grant-advisor`: turn
|
|
3
|
-
*
|
|
4
|
-
* transcript — can act on.
|
|
2
|
+
* `sandbox-grant-advisor`: turn an environment failure that has no path forward
|
|
3
|
+
* into a diagnosis the model — and the user reading the transcript — can act on.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* ## The two failures it recognizes
|
|
6
|
+
*
|
|
7
|
+
* **Workspace provisioning (Windows ACL).** Three reports of one signature
|
|
8
|
+
* (`#7538`, `#7622`, `#7646`) describe the same shape: the host-side write grant
|
|
9
|
+
* for a sandboxed workspace cannot be applied, every sandboxed command then
|
|
10
|
+
* fails identically **before it runs**, and the error text is a bare Win32 line:
|
|
10
11
|
*
|
|
11
12
|
* SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
|
|
12
13
|
*
|
|
13
14
|
* The grant is materialized lazily on the first confined call and nothing is
|
|
14
15
|
* cached when it throws, so the failure repeats per command rather than once
|
|
15
16
|
* (850 calls / 39 sessions in `#7622`; 52,588 output tokens with no output in
|
|
16
|
-
* `#7538`). The
|
|
17
|
-
*
|
|
18
|
-
* the caller `WRITE_OWNER` — never reaches the user, so sessions escape into
|
|
17
|
+
* `#7538`). The remedy the backend documents — the directory must grant the
|
|
18
|
+
* caller `WRITE_OWNER` — never reaches the user, so sessions escape into
|
|
19
19
|
* `danger-full-access` or die on the model's output cap.
|
|
20
20
|
*
|
|
21
|
+
* **Persistent shell startup (#7638).** With the `minimal` preset on Windows the
|
|
22
|
+
* only shell tool is a persistent PTY (`dsh-terminal-bash` +
|
|
23
|
+
* `dsh-tool-pwsh-persistent`), and under a *confining* sandbox mode every call
|
|
24
|
+
* fails instantly with
|
|
25
|
+
*
|
|
26
|
+
* PTY shell exited during startup
|
|
27
|
+
*
|
|
28
|
+
* — the backend cannot create the pseudo-console inside the sandbox, so the
|
|
29
|
+
* child exits before its first prompt. Retrying never helps, the message points
|
|
30
|
+
* at no cause, and because `minimal` mounts no fallback shell tool the session
|
|
31
|
+
* has no command execution left at all. The reporter's own three-arm control
|
|
32
|
+
* makes the sandbox mode the discriminator: minimal × confining fails, minimal ×
|
|
33
|
+
* `danger-full-access` succeeds, `standard` (one-shot shell) × confining
|
|
34
|
+
* succeeds.
|
|
35
|
+
*
|
|
21
36
|
* ## Where it acts, and why there
|
|
22
37
|
*
|
|
23
38
|
* One listener on the public `tools/post-execute` waterfall
|
|
@@ -28,51 +43,73 @@
|
|
|
28
43
|
* to the model in the same step (`PostToolDecision`'s `additionalContexts`,
|
|
29
44
|
* a durable user-role message).
|
|
30
45
|
*
|
|
31
|
-
* `ctx.sandbox.confine(argv, policy, signal)` sees the failure too,
|
|
32
|
-
* do this: its signature carries no agent, so a wrapper could detect
|
|
33
|
-
* condition and never deliver a word about it to the session that is stuck.
|
|
46
|
+
* `ctx.sandbox.confine(argv, policy, signal)` sees the confinement failure too,
|
|
47
|
+
* and cannot do this: its signature carries no agent, so a wrapper could detect
|
|
48
|
+
* the condition and never deliver a word about it to the session that is stuck.
|
|
49
|
+
*
|
|
50
|
+
* The PTY family needs one fact the failure text does not carry — the effective
|
|
51
|
+
* sandbox mode — and takes it from `ctx.sandboxPolicy.resolve({ session })`:
|
|
52
|
+
* the same resolver the terminal layer calls before spawning, with the same
|
|
53
|
+
* session. See `src/mode.ts` for why that lookup is guarded rather than
|
|
54
|
+
* imported, and what happens when it cannot answer.
|
|
34
55
|
*
|
|
35
56
|
* ## What it does
|
|
36
57
|
*
|
|
37
|
-
* 1. **One durable advisory per agent.** On the first recognized
|
|
38
|
-
* failure, the failing tool result is enriched with a user-role
|
|
39
|
-
* names the missing right (`WRITE_OWNER` on the
|
|
40
|
-
* `SeSecurityPrivilege`), gives the unelevated one-line
|
|
41
|
-
* gives the discriminator that separates a Modify-only
|
|
42
|
-
* wrong prerequisite.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* the
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
58
|
+
* 1. **One durable advisory per agent, per family.** On the first recognized
|
|
59
|
+
* failure of a family, the failing tool result is enriched with a user-role
|
|
60
|
+
* notice. For the ACL family it names the missing right (`WRITE_OWNER` on the
|
|
61
|
+
* directory, not `SeSecurityPrivilege`), gives the unelevated one-line
|
|
62
|
+
* `icacls` remedy, and gives the discriminator that separates a Modify-only
|
|
63
|
+
* directory from a wrong prerequisite. For the PTY family it names the
|
|
64
|
+
* combination that fails (persistent PTY × a confining mode), states the
|
|
65
|
+
* resolved mode, says plainly that no command can fix it, and hands the
|
|
66
|
+
* user-side preset choice over. Both ride `additionalContexts`, so the model
|
|
67
|
+
* sees the diagnosis beside the failure rather than only in a log it never
|
|
68
|
+
* reads.
|
|
69
|
+
* 2. **A bounded fail-fast, ACL family only.** With `enforceAfter` set, a call
|
|
70
|
+
* this plugin has *watched fail* this way is refused at `tools/pre-execute`
|
|
71
|
+
* once the environment has failed at least that many times. It is off by
|
|
72
|
+
* default: the useful signal here is the diagnosis, and a plugin that blocks
|
|
73
|
+
* command execution for a reason it merely recognizes is a risk, not a
|
|
74
|
+
* feature. See the README for why the blocking half is deliberately narrow
|
|
75
|
+
* and why it does not cover the PTY family.
|
|
76
|
+
* 3. **A disclosure when it withholds.** The PTY advisory is only sent when the
|
|
77
|
+
* resolved mode actually confines; if the mode is not confining, or cannot be
|
|
78
|
+
* resolved at all, the failure is left exactly as it was **and the host log
|
|
79
|
+
* says so once**. Silence alone would make "the sandbox is not the cause" and
|
|
80
|
+
* "this plugin could not tell" indistinguishable from the outside.
|
|
50
81
|
*
|
|
51
82
|
* ## Honest boundaries
|
|
52
83
|
*
|
|
53
84
|
* - **The Windows path cannot be witnessed on macOS**, where this plugin was
|
|
54
85
|
* built and tested. What is tested is the decision layer: classification,
|
|
55
|
-
* once-per-agent delivery, the
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
86
|
+
* once-per-agent-per-family delivery, the sandbox-mode gate and its
|
|
87
|
+
* fail-closed behaviour, the fail-fast budget, and the wiring to the real
|
|
88
|
+
* `ToolRuntime` — against synthetic results carrying the producers' exact
|
|
89
|
+
* error shapes, with the formats taken from
|
|
90
|
+
* `packages/subprocess/win32-process/src/errors.ts` and
|
|
91
|
+
* `packages/terminal/terminal-bash/src/{index,session}.ts`.
|
|
59
92
|
* - **It does not repair anything.** No ACL is written, no privilege is
|
|
60
|
-
* requested, nothing is elevated
|
|
93
|
+
* requested, nothing is elevated, no preset is installed and no mode is
|
|
94
|
+
* changed: both remedies are the user's to apply.
|
|
61
95
|
* - **It complements, rather than replaces, `repeat-guard-escalation`.** That
|
|
62
96
|
* guard keys on *call identity* (identical arguments retried); this one keys
|
|
63
97
|
* on the *environment signature*, which is how several different commands can
|
|
64
98
|
* share one cause. They can be mounted together.
|
|
65
|
-
* - **The real fix is upstream
|
|
66
|
-
* condition at the site that knows it (`grantWrite` computes
|
|
99
|
+
* - **The real fix is upstream**, in both families: the ACL failure should name
|
|
100
|
+
* the outstanding condition at the site that knows it (`grantWrite` computes
|
|
67
101
|
* `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
|
|
68
|
-
* false)
|
|
102
|
+
* false), and the PTY startup path should either report "this sandbox mode is
|
|
103
|
+
* incompatible with the PTY backend" or fall back to a one-shot shell. This
|
|
104
|
+
* plugin is the stopgap.
|
|
69
105
|
*
|
|
70
106
|
* @module @argszero/cordis-plugin-sandbox-grant-advisor
|
|
71
107
|
*/
|
|
72
108
|
import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
73
|
-
import { advisoryText, denialText,
|
|
74
|
-
import { classifyProvisioningFailure } from './signature.js';
|
|
75
|
-
import {
|
|
109
|
+
import { advisoryText, ACL_DISCUSSIONS, denialText, PTY_DISCUSSIONS } from './advice.js';
|
|
110
|
+
import { classifyProvisioningFailure, classifyPtyStartupFailure } from './signature.js';
|
|
111
|
+
import { confines, resolveSandboxMode } from './mode.js';
|
|
112
|
+
import { advisedOf, callKey, observe, observeSuccess, recordAdvice, recordDenial, recordWithheld, shouldDeny, } from './state.js';
|
|
76
113
|
export const name = 'sandbox-grant-advisor';
|
|
77
114
|
/** The tool pipeline this plugin observes and (optionally) gates. */
|
|
78
115
|
export const inject = ['tools'];
|
|
@@ -90,6 +127,14 @@ export const SOURCE_KIND = 'sandbox-grant-advisor';
|
|
|
90
127
|
export const DEFAULT_ENFORCE_AFTER = 0;
|
|
91
128
|
/** Default denial budget once the blocking half is enabled. */
|
|
92
129
|
export const DEFAULT_MAX_DENIALS = 2;
|
|
130
|
+
/**
|
|
131
|
+
* The family the optional blocking half applies to.
|
|
132
|
+
*
|
|
133
|
+
* The ACL remedy is a command the user can run while the session continues; the
|
|
134
|
+
* PTY remedy is a preset swap between sessions. Refusing calls is only useful
|
|
135
|
+
* in the first case — see `denialText` in `src/advice.ts`.
|
|
136
|
+
*/
|
|
137
|
+
export const ENFORCED_FAMILY = 'acl-provisioning';
|
|
93
138
|
/** Compile one `*`-wildcard pattern to an anchored RegExp; all else is literal. */
|
|
94
139
|
function wildcardToRegExp(pattern) {
|
|
95
140
|
const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, String.raw `\$&`);
|
|
@@ -127,15 +172,25 @@ function failureText(result) {
|
|
|
127
172
|
return result.error.message.length > 0 ? `${result.error.message}\n${rendered}` : rendered;
|
|
128
173
|
}
|
|
129
174
|
/**
|
|
130
|
-
* The one-line host-side account of a recognized failure.
|
|
175
|
+
* The one-line host-side account of a recognized ACL failure.
|
|
131
176
|
* @param failure - the recognized failure.
|
|
132
177
|
* @returns a single log line.
|
|
133
178
|
*/
|
|
134
|
-
function
|
|
179
|
+
function aclHostLine(failure) {
|
|
135
180
|
const where = failure.detail.length === 0 ? '' : ` at ${failure.detail}`;
|
|
136
181
|
return `sandbox-grant-advisor: workspace ACL provisioning failed (${failure.api} Win32 `
|
|
137
182
|
+ `${String(failure.win32Code)})${where} — sandboxed commands will keep failing until the directory grants `
|
|
138
|
-
+ `this account Full control; advisory delivered to the model (discussions ${
|
|
183
|
+
+ `this account Full control; advisory delivered to the model (discussions ${ACL_DISCUSSIONS})`;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The one-line host-side account of a recognized persistent-shell failure.
|
|
187
|
+
* @param mode - the resolved sandbox mode the failing call ran under.
|
|
188
|
+
* @returns a single log line.
|
|
189
|
+
*/
|
|
190
|
+
function ptyHostLine(mode) {
|
|
191
|
+
return `sandbox-grant-advisor: persistent shell exited during startup under sandbox mode `
|
|
192
|
+
+ `"${mode}" — a PTY backend cannot start under a confining mode, and retrying cannot help; advisory `
|
|
193
|
+
+ `delivered to the model (discussion ${PTY_DISCUSSIONS})`;
|
|
139
194
|
}
|
|
140
195
|
/**
|
|
141
196
|
* Wrap one notice as a user-role message.
|
|
@@ -167,6 +222,12 @@ function notice(text, summary) {
|
|
|
167
222
|
function prepend(ours, theirs) {
|
|
168
223
|
return [ours, ...theirs ?? []];
|
|
169
224
|
}
|
|
225
|
+
/** The one-line transcript summary for a recognized failure. */
|
|
226
|
+
function summaryOf(failure, mode) {
|
|
227
|
+
return failure.family === 'pty-startup'
|
|
228
|
+
? `persistent shell exited during startup under sandbox mode "${String(mode)}"`
|
|
229
|
+
: `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`;
|
|
230
|
+
}
|
|
170
231
|
/**
|
|
171
232
|
* Install the advisor.
|
|
172
233
|
* @param ctx - context carrying the tool pipeline.
|
|
@@ -195,6 +256,26 @@ export function apply(ctx, config = {}) {
|
|
|
195
256
|
return false;
|
|
196
257
|
return !excludePatterns.some(pattern => pattern.test(toolName));
|
|
197
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Leave a recognized failure exactly as it is, and say so once on the host.
|
|
261
|
+
*
|
|
262
|
+
* Withholding is a decision, not an absence: the transcript shows a bare error
|
|
263
|
+
* either way, so the difference between "this is not the sandbox's doing" and
|
|
264
|
+
* "this plugin could not tell" has to be recorded where a maintainer reads it.
|
|
265
|
+
* Once per agent, because a loop can produce dozens of these.
|
|
266
|
+
* @param agent - the agent whose failure was withheld.
|
|
267
|
+
* @param state - the agent's state, to keep the note to one.
|
|
268
|
+
* @param why - what stopped the advisory.
|
|
269
|
+
* @returns undefined, so callers can `return withhold(...)`.
|
|
270
|
+
*/
|
|
271
|
+
function withhold(agent, state, why) {
|
|
272
|
+
if (state?.withheld === true)
|
|
273
|
+
return undefined;
|
|
274
|
+
states.set(agent, recordWithheld(state));
|
|
275
|
+
ctx.logger.warn(`sandbox-grant-advisor: persistent-shell startup failure recognized but no advisory sent — ${why}; the raw `
|
|
276
|
+
+ `error is left exactly as it is, so this is NOT a claim that the sandbox is unrelated (discussion ${PTY_DISCUSSIONS})`);
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
198
279
|
/**
|
|
199
280
|
* Read one settled call: advance the state, and decide whether it is the
|
|
200
281
|
* failure the model needs told about.
|
|
@@ -217,17 +298,81 @@ export function apply(ctx, config = {}) {
|
|
|
217
298
|
states.set(agent, observeSuccess(previous, key));
|
|
218
299
|
return undefined;
|
|
219
300
|
}
|
|
220
|
-
|
|
301
|
+
// The two families read different fields, on purpose. The ACL signature
|
|
302
|
+
// carries an API name plus a Win32 code, which a command's own output does
|
|
303
|
+
// not fabricate, so that family may read the merged text (`error.message`
|
|
304
|
+
// with the rendered content as its fallback). The persistent-shell
|
|
305
|
+
// signature is a bare sentence, and the rendered content is exactly where a
|
|
306
|
+
// runner-failure path could carry a command's output — so this family reads
|
|
307
|
+
// `error.message` alone, the field the layer that threw it filled in. A
|
|
308
|
+
// sentence quoted from a log must never make this plugin tell a working
|
|
309
|
+
// session that its shell is dead.
|
|
310
|
+
//
|
|
311
|
+
// Honest about the limit: with the current `dsh-tools` runtime the rendered
|
|
312
|
+
// content of an error result is derived from `error.message`, so today the
|
|
313
|
+
// two reads agree and this choice is not observable from outside — an
|
|
314
|
+
// injection arm that swaps in the merged text leaves the suite green, and
|
|
315
|
+
// that is recorded rather than papered over. The narrower read is kept
|
|
316
|
+
// because the agreement is the runtime's rendering choice, not a promise
|
|
317
|
+
// this plugin can rely on: a tool whose `render` produces output of its own
|
|
318
|
+
// is exactly the case the whole-line rule exists for.
|
|
319
|
+
const failure = classifyProvisioningFailure(failureText(result))
|
|
320
|
+
?? classifyPtyStartupFailure(result.error.message);
|
|
221
321
|
if (failure === undefined)
|
|
222
322
|
return undefined;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
323
|
+
// The PTY diagnosis IS the sandbox mode, so it is resolved before anything
|
|
324
|
+
// is recorded: an unconfined mode is not this family's story, and a mode
|
|
325
|
+
// that cannot be resolved is not something to guess at. Either way the
|
|
326
|
+
// failure is left untouched and the host log accounts for the silence.
|
|
327
|
+
if (failure.family === 'pty-startup') {
|
|
328
|
+
const resolution = resolveSandboxMode(ctx, agent);
|
|
329
|
+
if (!resolution.ok)
|
|
330
|
+
return withhold(agent, previous, resolution.withheld);
|
|
331
|
+
const mode = resolution.mode;
|
|
332
|
+
if (!confines(mode)) {
|
|
333
|
+
return withhold(agent, previous, `the failing call ran under \`${mode}\`, where the shell is not spawned through the sandbox`);
|
|
334
|
+
}
|
|
335
|
+
if (!claimAdvice(agent, previous, failure, key))
|
|
336
|
+
return undefined;
|
|
337
|
+
ctx.logger.warn(ptyHostLine(mode));
|
|
338
|
+
return notice(advisoryText(failure, advisoryContext(exec.name, mode)), summaryOf(failure, mode));
|
|
227
339
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
340
|
+
if (!claimAdvice(agent, previous, failure, key))
|
|
341
|
+
return undefined;
|
|
342
|
+
ctx.logger.warn(aclHostLine(failure));
|
|
343
|
+
return notice(advisoryText(failure, advisoryContext(exec.name)), summaryOf(failure));
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Record one recognized failure and claim the once-per-agent advisory for its
|
|
347
|
+
* family.
|
|
348
|
+
* @param agent - the agent whose call failed.
|
|
349
|
+
* @param previous - the agent's state before this call, if any.
|
|
350
|
+
* @param failure - the recognized failure.
|
|
351
|
+
* @param key - the identity of the failing call.
|
|
352
|
+
* @returns true when this call is the one that must carry the diagnosis.
|
|
353
|
+
*/
|
|
354
|
+
function claimAdvice(agent, previous, failure, key) {
|
|
355
|
+
const advanced = observe(previous, failure.family, failure, key);
|
|
356
|
+
const first = !advisedOf(advanced, failure.family);
|
|
357
|
+
states.set(agent, first ? recordAdvice(advanced, failure.family) : advanced);
|
|
358
|
+
return first;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The advisory context for one failing call.
|
|
362
|
+
*
|
|
363
|
+
* Built here rather than at each call site so the optional fields are only
|
|
364
|
+
* present when they are known — `exactOptionalPropertyTypes` would otherwise
|
|
365
|
+
* accept an explicit `undefined` that the consumer would have to un-learn.
|
|
366
|
+
* @param tool - the failing tool's name.
|
|
367
|
+
* @param mode - the resolved sandbox mode, for the family that needs it.
|
|
368
|
+
* @returns the context to pass to `advisoryText`.
|
|
369
|
+
*/
|
|
370
|
+
function advisoryContext(tool, mode) {
|
|
371
|
+
return {
|
|
372
|
+
...href === undefined ? {} : { href },
|
|
373
|
+
tool,
|
|
374
|
+
...mode === undefined ? {} : { mode },
|
|
375
|
+
};
|
|
231
376
|
}
|
|
232
377
|
// Observe-and-enrich, never veto by itself: delegate first, then fold this
|
|
233
378
|
// plugin's notice onto whatever came back. `additionalContexts` rides both
|
|
@@ -264,17 +409,29 @@ export function apply(ctx, config = {}) {
|
|
|
264
409
|
if (agent === undefined || !tracked(exec.name))
|
|
265
410
|
return next();
|
|
266
411
|
const state = states.get(agent);
|
|
267
|
-
|
|
412
|
+
const key = callKey(exec.name, exec.arguments);
|
|
413
|
+
if (!shouldDeny(state, ENFORCED_FAMILY, key, enforceAfter, maxDenials))
|
|
268
414
|
return next();
|
|
269
415
|
if (state === undefined)
|
|
270
416
|
return next();
|
|
417
|
+
const record = state.families[ENFORCED_FAMILY];
|
|
418
|
+
if (record === undefined)
|
|
419
|
+
return next();
|
|
420
|
+
// `denialText` speaks the ACL family's language (the `icacls` line), so the
|
|
421
|
+
// record is asked to be that family's before it is used: the family tag on
|
|
422
|
+
// a record and the key it is stored under are not the same fact, and this
|
|
423
|
+
// is the one place where confusing them would put the wrong remedy in
|
|
424
|
+
// front of the model.
|
|
425
|
+
const failure = record.last;
|
|
426
|
+
if (failure.family !== 'acl-provisioning')
|
|
427
|
+
return next();
|
|
271
428
|
// Tag before delegating, and spend the budget immediately: the denial must
|
|
272
429
|
// be accounted for even if a later listener replaces this decision.
|
|
273
430
|
ownDenials.add(exec);
|
|
274
|
-
states.set(agent, recordDenial(state));
|
|
431
|
+
states.set(agent, recordDenial(state, ENFORCED_FAMILY));
|
|
275
432
|
return Promise.resolve({
|
|
276
433
|
kind: 'deny',
|
|
277
|
-
reason: denialText(
|
|
434
|
+
reason: denialText(failure, record.observations, record.denials + 1, maxDenials),
|
|
278
435
|
});
|
|
279
436
|
}
|
|
280
437
|
catch (error) {
|
package/lib/mode.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sandbox mode a call ran under, and whether that mode confines.
|
|
3
|
+
*
|
|
4
|
+
* The persistent-shell diagnosis is only true under a **confining** mode. The
|
|
5
|
+
* terminal backend hands the shell argv straight through when the resolved mode
|
|
6
|
+
* is `danger-full-access` and confines it otherwise
|
|
7
|
+
* (`packages/terminal/terminal-bash/src/index.ts`:
|
|
8
|
+
* `if (policy.mode === 'danger-full-access') return argv`), so the same
|
|
9
|
+
* `PTY shell exited during startup` under `danger-full-access` is a different
|
|
10
|
+
* story — a broken or missing shell — and this plugin must stay silent about it
|
|
11
|
+
* rather than assert a sandbox cause it cannot support. The report that frames
|
|
12
|
+
* this family (#7638) says the same thing from the other side: its author's
|
|
13
|
+
* three-arm control shows the mode is the discriminator (minimal × confining
|
|
14
|
+
* fails, minimal × `danger-full-access` succeeds, standard × confining
|
|
15
|
+
* succeeds).
|
|
16
|
+
*
|
|
17
|
+
* The answer is taken from `ctx.sandboxPolicy.resolve({ session })` — the same
|
|
18
|
+
* resolver the terminal layer itself calls before spawning, with the same
|
|
19
|
+
* session — so what is quoted in the advisory is the policy that actually
|
|
20
|
+
* governed the failing call, not a guess reconstructed from configuration.
|
|
21
|
+
*
|
|
22
|
+
* ## Why this is a guarded lookup instead of an import
|
|
23
|
+
*
|
|
24
|
+
* `@deepseek-ai/dsh-sandbox-policy` is **optional** in this plugin's world: a
|
|
25
|
+
* composition may simply not mount the service, and this plugin must degrade to
|
|
26
|
+
* silence rather than fail to load. Two consequences shape this module:
|
|
27
|
+
*
|
|
28
|
+
* - A declared peer dependency is a claim about versions, and this package's
|
|
29
|
+
* packaging guard refuses both an import that is not declared and a
|
|
30
|
+
* declaration that is not imported. A *type-only* import would therefore turn
|
|
31
|
+
* an optional integration into a mandatory claim on every line the peer range
|
|
32
|
+
* admits — and a range that admits a line nobody ran is exactly the defect
|
|
33
|
+
* that guard exists to prevent.
|
|
34
|
+
* - What is left is the consumer-side capability guard: look the service up,
|
|
35
|
+
* check the shape of the answer instead of trusting it, and **fail closed**
|
|
36
|
+
* (`{ ok: false }`): an unresolvable mode withholds the advisory, and the
|
|
37
|
+
* caller discloses that withholding on the host side. An unresolvable mode is
|
|
38
|
+
* emphatically *not* an invitation to fall back to the deployment default —
|
|
39
|
+
* a session that overrode its mode to `danger-full-access` would then be
|
|
40
|
+
* diagnosed as if it were confined.
|
|
41
|
+
*
|
|
42
|
+
* The call site this module depends on is stable across every line the peer
|
|
43
|
+
* range claims: `resolve(request?: SandboxPolicyRequest): SandboxExecutionPolicy`
|
|
44
|
+
* is declared at the same position of `lib/types/index.d.ts` in every published
|
|
45
|
+
* build from `0.1.2-rc.1` to `0.1.7-rc.1`, and `Agent.session` is present on
|
|
46
|
+
* the same span of `@deepseek-ai/dsh-agent`.
|
|
47
|
+
*
|
|
48
|
+
* @module
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* Whether a mode confines the process it is asked to spawn.
|
|
52
|
+
* @param mode - the resolved mode.
|
|
53
|
+
* @returns true for every mode except `danger-full-access`.
|
|
54
|
+
*/
|
|
55
|
+
export function confines(mode) {
|
|
56
|
+
return mode !== 'danger-full-access';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Narrow an optional lookup result to the resolver shape.
|
|
60
|
+
* @param value - whatever `ctx.get('sandboxPolicy')` returned.
|
|
61
|
+
* @returns the same object, typed as the resolver, or undefined.
|
|
62
|
+
*/
|
|
63
|
+
function asPolicy(value) {
|
|
64
|
+
if (value === null || typeof value !== 'object')
|
|
65
|
+
return undefined;
|
|
66
|
+
if (typeof value.resolve !== 'function')
|
|
67
|
+
return undefined;
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The agent's live session, read defensively.
|
|
72
|
+
*
|
|
73
|
+
* `Agent.session` is declared by `@deepseek-ai/dsh-agent`'s runtime face and is
|
|
74
|
+
* present on every claimed line; it is read as `unknown` here anyway, because a
|
|
75
|
+
* gate that trusts the shape of its input is the same gate that reports a cause
|
|
76
|
+
* from the wrong world when the input is not what it expected.
|
|
77
|
+
* @param agent - the agent whose call failed.
|
|
78
|
+
* @returns the session object, or undefined.
|
|
79
|
+
*/
|
|
80
|
+
function agentSession(agent) {
|
|
81
|
+
const session = agent.session;
|
|
82
|
+
return session !== null && typeof session === 'object' ? session : undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The mode inside a resolved policy, if it is one this harness defines.
|
|
86
|
+
* @param resolved - the resolver's return value.
|
|
87
|
+
* @returns the mode, or undefined when the value is not a recognizable policy.
|
|
88
|
+
*/
|
|
89
|
+
function recognizedMode(resolved) {
|
|
90
|
+
if (resolved === null || typeof resolved !== 'object')
|
|
91
|
+
return undefined;
|
|
92
|
+
const mode = resolved.mode;
|
|
93
|
+
return mode === 'read-only' || mode === 'workspace-write' || mode === 'danger-full-access'
|
|
94
|
+
? mode
|
|
95
|
+
: undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the effective sandbox mode for one agent's call.
|
|
99
|
+
*
|
|
100
|
+
* The service is looked up through `ctx.get` — the documented optional lookup —
|
|
101
|
+
* and the resolver is invoked with the agent's own session, so a session that
|
|
102
|
+
* logged a `sandbox/mode` override is answered with that override rather than
|
|
103
|
+
* with the deployment default.
|
|
104
|
+
* @param ctx - the plugin's context.
|
|
105
|
+
* @param agent - the agent whose call failed.
|
|
106
|
+
* @returns the mode, or the reason it could not be resolved.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveSandboxMode(ctx, agent) {
|
|
109
|
+
const policy = asPolicy(ctx.get('sandboxPolicy'));
|
|
110
|
+
if (policy === undefined) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
withheld: 'no `sandboxPolicy` service is mounted in this composition, so the effective mode is unknown',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const session = agentSession(agent);
|
|
117
|
+
if (session === undefined) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
withheld: 'the agent exposes no session, and the policy must be resolved from it rather than from the deployment default',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
let resolved;
|
|
124
|
+
try {
|
|
125
|
+
resolved = policy.resolve({ session });
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return { ok: false, withheld: `\`sandboxPolicy.resolve\` threw (${String(error)})` };
|
|
129
|
+
}
|
|
130
|
+
const mode = recognizedMode(resolved);
|
|
131
|
+
if (mode === undefined) {
|
|
132
|
+
return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a recognizable mode' };
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, mode };
|
|
135
|
+
}
|
package/lib/signature.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recognize the
|
|
2
|
+
* Recognize the two environment failures this plugin explains, and refuse
|
|
3
|
+
* everything else.
|
|
4
|
+
*
|
|
5
|
+
* ## The ACL provisioning failure (`acl-provisioning`)
|
|
3
6
|
*
|
|
4
7
|
* The harness's Windows sandbox provisions a workspace by writing the
|
|
5
8
|
* directory's DACL and its mandatory-integrity label in **one**
|
|
@@ -10,7 +13,7 @@
|
|
|
10
13
|
* it. Every sandboxed command then fails the same way, forever, because the
|
|
11
14
|
* grant is materialized lazily and nothing is cached on the failure path.
|
|
12
15
|
*
|
|
13
|
-
* Recognizing the string is therefore the whole job of this
|
|
16
|
+
* Recognizing the string is therefore the whole job of this half, and the
|
|
14
17
|
* recognition is deliberately narrow:
|
|
15
18
|
*
|
|
16
19
|
* - **Only the two `...NamedSecurityInfoW` operations are classified.** Their
|
|
@@ -27,8 +30,32 @@
|
|
|
27
30
|
* the advisory can quote the exact line the model and the user are looking
|
|
28
31
|
* at, and the path can be re-used in the fix command.
|
|
29
32
|
*
|
|
33
|
+
* ## The persistent-shell startup failure (`pty-startup`)
|
|
34
|
+
*
|
|
35
|
+
* `dsh-terminal-bash` throws `PTY shell exited during startup` when the shell
|
|
36
|
+
* it spawned through the sandbox exits before reaching its first prompt
|
|
37
|
+
* (`src/session.ts` and `src/index.ts`, both on the same `waitReason ===
|
|
38
|
+
* 'session_exit'` branch). The text names no cause and, under the `minimal`
|
|
39
|
+
* preset — whose only shell tool is a persistent PTY — there is no other shell
|
|
40
|
+
* tool left to fall back on, so the model reads it as "the command failed" and
|
|
41
|
+
* retries forever. #7638 is the report: 33 consecutive failures under
|
|
42
|
+
* `workspace-write`, none under `danger-full-access`, with the reporter's own
|
|
43
|
+
* three-arm control showing the sandbox mode is the discriminator.
|
|
44
|
+
*
|
|
45
|
+
* This family is recognized on an **exact line**, not on a substring, and that
|
|
46
|
+
* is deliberate. The producer's message has no detail field at all — the whole
|
|
47
|
+
* message is the sentence — so anything that merely *contains* the phrase is
|
|
48
|
+
* quoting it (a transcript, a log a failing command printed, a pasted issue
|
|
49
|
+
* body) rather than producing it. The sibling throw on the same branch, `PTY
|
|
50
|
+
* shell did not reach readiness before startup timeout`, is **not** classified
|
|
51
|
+
* here: it means the shell started and then did not reach a prompt, which is a
|
|
52
|
+
* different cause space (a slow or blocked shell) with a different remedy, and
|
|
53
|
+
* a classifier that names a wrong cause is worse than one that stays silent.
|
|
54
|
+
*
|
|
30
55
|
* @module
|
|
31
56
|
*/
|
|
57
|
+
/** The exact text `dsh-terminal-bash` throws when the shell exits during startup. */
|
|
58
|
+
export const PTY_STARTUP_EXIT = 'PTY shell exited during startup';
|
|
32
59
|
/**
|
|
33
60
|
* The producer's format is fixed by `Win32Error`
|
|
34
61
|
* (`packages/subprocess/win32-process/src/errors.ts`):
|
|
@@ -52,7 +79,7 @@ function splitDetail(detail) {
|
|
|
52
79
|
return { label, path };
|
|
53
80
|
}
|
|
54
81
|
/**
|
|
55
|
-
* Classify one failure message.
|
|
82
|
+
* Classify one failure message against the ACL family.
|
|
56
83
|
* @param message - the failure text, from the result's `error.message` or its rendered content.
|
|
57
84
|
* @returns the recognized failure, or undefined when this is not a provisioning failure.
|
|
58
85
|
*/
|
|
@@ -68,14 +95,38 @@ export function classifyProvisioningFailure(message) {
|
|
|
68
95
|
const klass = api === 'GetNamedSecurityInfoW'
|
|
69
96
|
? 'read-denied'
|
|
70
97
|
: code === ACCESS_DENIED ? 'apply-denied' : 'apply-other';
|
|
71
|
-
return { klass, api, win32Code: code, detail, ...splitDetail(detail) };
|
|
98
|
+
return { family: 'acl-provisioning', klass, api, win32Code: code, detail, ...splitDetail(detail) };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Classify one failure message as the persistent-shell startup failure.
|
|
102
|
+
*
|
|
103
|
+
* Recognition is by **whole line**, because the producer's message is a bare
|
|
104
|
+
* sentence with no fields of its own: a line equal to
|
|
105
|
+
* {@link PTY_STARTUP_EXIT} — with only the `Error: ` envelope a tool result adds
|
|
106
|
+
* in front of it — is the producer. A longer line that happens to contain the
|
|
107
|
+
* sentence is something quoting it (a transcript, a log the failing command
|
|
108
|
+
* printed, a pasted issue body), and advising about the sandbox there would be
|
|
109
|
+
* advice about the wrong thing.
|
|
110
|
+
* @param message - the failure text, from the result's `error.message` or its rendered content.
|
|
111
|
+
* @returns the recognized failure, or undefined when this is not one.
|
|
112
|
+
*/
|
|
113
|
+
export function classifyPtyStartupFailure(message) {
|
|
114
|
+
for (const raw of message.split('\n')) {
|
|
115
|
+
const line = raw.trim();
|
|
116
|
+
if (line === PTY_STARTUP_EXIT || line === `Error: ${PTY_STARTUP_EXIT}`) {
|
|
117
|
+
return { family: 'pty-startup', line: PTY_STARTUP_EXIT };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
72
121
|
}
|
|
73
122
|
/**
|
|
74
123
|
* The one-line failure the producer wrote, for quoting back verbatim.
|
|
75
124
|
* @param failure - a recognized failure.
|
|
76
|
-
* @returns the message text
|
|
125
|
+
* @returns the message text the producing layer would have produced.
|
|
77
126
|
*/
|
|
78
127
|
export function failureLine(failure) {
|
|
128
|
+
if (failure.family === 'pty-startup')
|
|
129
|
+
return failure.line;
|
|
79
130
|
const suffix = failure.detail.length === 0 ? '' : `: ${failure.detail}`;
|
|
80
131
|
return `${failure.api} failed (Win32 ${failure.win32Code})${suffix}`;
|
|
81
132
|
}
|