@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/lib/index.js CHANGED
@@ -1,23 +1,38 @@
1
1
  /**
2
- * `sandbox-grant-advisor`: turn a Windows ACL provisioning failure that has no
3
- * path forward into a diagnosis the model — and the user reading the
4
- * transcript — can act on.
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
- * Three reports of one signature (`#7538`, `#7622`, `#7646`) describe the same
7
- * shape: the host-side write grant for a sandboxed workspace cannot be applied,
8
- * every sandboxed command then fails identically **before it runs**, and the
9
- * error text is a bare Win32 line:
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 `workspace-write` policy is simply unusable in such a
17
- * workspace, and the remedy the backend documents — the directory must grant
18
- * the caller `WRITE_OWNER` — never reaches the user, so sessions escape into
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, and cannot
32
- * do this: its signature carries no agent, so a wrapper could detect the
33
- * condition and never deliver a word about it to the session that is stuck.
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 provisioning
38
- * failure, the failing tool result is enriched with a user-role notice that
39
- * names the missing right (`WRITE_OWNER` on the directory, not
40
- * `SeSecurityPrivilege`), gives the unelevated one-line `icacls` remedy, and
41
- * gives the discriminator that separates a Modify-only directory from a
42
- * wrong prerequisite. Attached through `additionalContexts`, so the model
43
- * sees it beside the failure rather than only in a log the model never reads.
44
- * 2. **An optional bounded fail-fast.** With `enforceAfter` set, a call this
45
- * plugin has *watched fail* this way is refused at `tools/pre-execute` once
46
- * the environment has failed at least that many times. It is off by default:
47
- * the useful signal here is the diagnosis, and a plugin that blocks command
48
- * execution for a reason it merely recognizes is a risk, not a feature. See
49
- * the README for why the blocking half is deliberately narrow.
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 fail-fast budget, and the wiring to the real
56
- * `ToolRuntime` — against synthetic results carrying the producer's exact
57
- * error shape, with the format taken from
58
- * `packages/subprocess/win32-process/src/errors.ts`.
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: the `icacls` line is the user's to run.
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**: the failure should name the outstanding
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). This plugin is the stopgap.
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, DISCUSSIONS } from './advice.js';
74
- import { classifyProvisioningFailure } from './signature.js';
75
- import { callKey, observe, observeSuccess, recordAdvice, recordDenial, shouldDeny } from './state.js';
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 hostLine(failure) {
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 ${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
- const failure = classifyProvisioningFailure(failureText(result));
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
- const advanced = observe(previous, failure, key);
224
- if (advanced.advised) {
225
- states.set(agent, advanced);
226
- return undefined;
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
- states.set(agent, recordAdvice(advanced));
229
- ctx.logger.warn(hostLine(failure));
230
- return notice(advisoryText(failure, href), `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`);
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
- if (!shouldDeny(state, callKey(exec.name, exec.arguments), enforceAfter, maxDenials))
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(state.last, state.observations, state.denials + 1, maxDenials),
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 Windows ACL provisioning failure that has no path forward.
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 module, and the
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 a `Win32Error` would have produced.
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
  }