@argszero/cordis-plugin-sandbox-grant-advisor 0.9.1 → 0.11.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
@@ -2,7 +2,7 @@
2
2
  * `sandbox-grant-advisor`: turn an environment failure that has no path forward
3
3
  * into a diagnosis the model — and the user reading the transcript — can act on.
4
4
  *
5
- * ## The three failures it recognizes
5
+ * ## The four failures it recognizes
6
6
  *
7
7
  * **Workspace provisioning (Windows ACL).** Four reports of one signature
8
8
  * (`#7538`, `#7622`, `#7646`, `#7720`) describe the same shape: the host-side write grant
@@ -68,6 +68,26 @@
68
68
  * tool's own canonical output, never a line of rendered text — and which three
69
69
  * narrowings keep the recognition from firing on something else.
70
70
  *
71
+ * **Denied inside the workspace (Windows ACL, `#423`).** The fourth family is
72
+ * the other half of the backend the first one explains, and it is the first
73
+ * whose remedy is withheld on purpose. There the grant could not be applied at
74
+ * all; here it *was* applied — on the workspace root, once — and Windows ACE
75
+ * inheritance silently skipped objects whose DACL the caller could not write.
76
+ * The backend's provisioning check short-circuits on the root (`hasExactGrant`
77
+ * returns early once the root carries the ACE, `sandbox-windows-acl/src/acl.ts`),
78
+ * so those descendants are never revisited and the same command fails forever,
79
+ * while a directory the harness itself created works. It arrives at the same
80
+ * seam as the native-init family and for the same structural reason: a denied
81
+ * command exits nonzero and the shipped shell tools report that as a finished
82
+ * run, so the fact is in `ToolExecutionSuccess.value` rather than in an error.
83
+ * Unlike that family it needs no bespoke code — the executors stamp the denial
84
+ * onto the value as a structured triple (`sandbox: { mode, denied, enforcement? }`,
85
+ * produced by matching the backend's own refusal dialect in the captured
86
+ * stderr), so this plugin reads the harness's own reading of its own sandbox.
87
+ * See `src/signature.ts` for the facts that narrow it, and in particular for
88
+ * why a denial is the *designed* outcome in three other situations (outside the
89
+ * workspace, under `read-only`, a runner failure) and must never be advised.
90
+ *
71
91
  * ## Where it acts, and why there
72
92
  *
73
93
  * One listener on the public `tools/post-execute` waterfall
@@ -107,9 +127,15 @@
107
127
  * packaged desktop binary, which the plugin **measures and reports** rather
108
128
  * than assumes), and carries the one conversion a model can actually make —
109
129
  * rewrite the work as PowerShell or `cmd` when the program that could not
110
- * start was an MSYS2 one. All three ride `additionalContexts`, so the model
111
- * sees the diagnosis beside the failure rather than only in a log it never
112
- * reads.
130
+ * start was an MSYS2 one. For the workspace-denial family it prints the path
131
+ * it keyed on beside the root it tested it against, says that retrying is
132
+ * provably useless (the root-only provisioning check never revisits the
133
+ * object), gives the two-sided reachability rule, names the label variant of
134
+ * the same shape, and prints **no** repair command — the obvious one is
135
+ * refused by Windows with `ERROR_NONE_MAPPED (1332)`, and this project has no
136
+ * Windows host to verify a line on. All four ride `additionalContexts`, so the
137
+ * model sees the diagnosis beside the failure rather than only in a log it
138
+ * never reads.
113
139
  * 2. **A bounded fail-fast, ACL family only.** With `enforceAfter` set, a call
114
140
  * this plugin has *watched fail* this way is refused at `tools/pre-execute`
115
141
  * once the environment has failed at least that many times. It is off by
@@ -121,8 +147,13 @@
121
147
  * only sent when the resolved mode actually confines; if the mode is not
122
148
  * confining, or cannot be
123
149
  * resolved at all, the failure is left exactly as it was **and the host log
124
- * says so once**. Silence alone would make "the sandbox is not the cause" and
125
- * "this plugin could not tell" indistinguishable from the outside.
150
+ * says so once**. The workspace-denial advisory is withheld the same way when
151
+ * the workspace root — the fact the containment claim is tested against —
152
+ * cannot be resolved. Silence alone would make "the sandbox is not the cause" and
153
+ * "this plugin could not tell" indistinguishable from the outside. A denial
154
+ * the classifier *can* place outside the workspace is a different case and is
155
+ * left silent on purpose: that is the sanctioned escalation path, not a
156
+ * puzzle, and a note about it would be noise.
126
157
  *
127
158
  * ## Honest boundaries
128
159
  *
@@ -139,7 +170,10 @@
139
170
  * tools' own foreground projection with the reported codes, including the
140
171
  * signed form the reporter saw (`-1073741502`) and the real MSYS2 stderr, so the
141
172
  * recognition runs against the producer's data rather than against a message
142
- * this plugin invented.
173
+ * this plugin invented. The workspace-denial family is exercised the same way:
174
+ * the test builds the executors' own `sandbox` stamp and the shipped foreground
175
+ * projection, supplies `win32` as the platform fact, and covers both the
176
+ * recognized case and each of the narrowings that must stay silent.
143
177
  * - **It does not repair anything.** No ACL is written, no privilege is
144
178
  * requested, nothing is elevated, no environment variable is set for another
145
179
  * process, no preset is installed and no mode is changed: the remedies are the
@@ -148,7 +182,7 @@
148
182
  * guard keys on *call identity* (identical arguments retried); this one keys
149
183
  * on the *environment signature*, which is how several different commands can
150
184
  * share one cause. They can be mounted together.
151
- * - **The real fix is upstream**, in all three families: the ACL failure should
185
+ * - **The real fix is upstream**, in all four families: the ACL failure should
152
186
  * name
153
187
  * the outstanding condition at the site that knows it (`grantWrite` computes
154
188
  * `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
@@ -156,14 +190,19 @@
156
190
  * incompatible with the PTY backend" or fall back to a one-shot shell, and the
157
191
  * sandbox runner should be launched with the environment its own execution
158
192
  * needs (`ELECTRON_RUN_AS_NODE` when argv[0] is an Electron binary) or with a
159
- * documented, checkable refusal for MSYS2 programs. This plugin is the stopgap.
193
+ * documented, checkable refusal for MSYS2 programs. The workspace-denial family
194
+ * is the same shape once more: `grantWrite` returns early when the *root*
195
+ * already carries the ACE, so the descendants that missed the propagation are
196
+ * never repaired — the check would have to look past the root, or the denial
197
+ * surface would have to say *which* path was refused instead of only that one
198
+ * was. This plugin is the stopgap.
160
199
  *
161
200
  * @module @argszero/cordis-plugin-sandbox-grant-advisor
162
201
  */
163
202
  import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
164
- import { advisoryText, ACL_DISCUSSIONS, denialText, NATIVE_INIT_DISCUSSIONS, PTY_DISCUSSIONS } from './advice.js';
165
- import { classifyNativeInitDeath, classifyProvisioningFailure, classifyPtyStartupFailure } from './signature.js';
166
- import { confines, resolveSandboxMode } from './mode.js';
203
+ import { advisoryText, ACL_DISCUSSIONS, denialText, DISCUSSIONS_OF, NATIVE_INIT_DISCUSSIONS, PTY_DISCUSSIONS, WORKSPACE_DENIAL_DISCUSSIONS } from './advice.js';
204
+ import { classifyNativeInitDeath, classifyProvisioningFailure, classifyPtyStartupFailure, classifyWorkspaceDenial, hasWorkspaceDenialStamp, } from './signature.js';
205
+ import { confines, resolveSandboxMode, resolveWorkspaceRoot } from './mode.js';
167
206
  import { advisedOf, callKey, observe, observeSuccess, recordAdvice, recordDenial, recordWithheld, shouldDeny, } from './state.js';
168
207
  export const name = 'sandbox-grant-advisor';
169
208
  /** The tool pipeline this plugin observes and (optionally) gates. */
@@ -303,6 +342,10 @@ function summaryOf(failure, mode) {
303
342
  return `sandboxed command never started (exit ${String(failure.rawExitCode)}, STATUS_DLL_INIT_FAILED) `
304
343
  + `under sandbox mode "${String(mode)}"`;
305
344
  }
345
+ if (failure.family === 'workspace-denial') {
346
+ const subject = failure.paths[0] ?? 'a path in the workspace';
347
+ return `denied inside the workspace (${subject}) under sandbox mode "${failure.mode}"`;
348
+ }
306
349
  return `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`;
307
350
  }
308
351
  /**
@@ -346,16 +389,16 @@ export function apply(ctx, config = {}) {
346
389
  * @param agent - the agent whose failure was withheld.
347
390
  * @param state - the agent's state, to keep the note to one.
348
391
  * @param why - what stopped the advisory.
349
- * @param failure - the recognized failure that was withheld; only the two
350
- * mode-gated families reach this function, so the thread it cites is exact.
392
+ * @param family - the recognized family that was withheld. Only families whose
393
+ * gate can fail closed reach this function, so the thread it cites is exact.
351
394
  * @returns undefined, so callers can `return withhold(...)`.
352
395
  */
353
- function withhold(agent, state, why, failure) {
396
+ function withhold(agent, state, why, family) {
354
397
  if (state?.withheld === true)
355
398
  return undefined;
356
399
  states.set(agent, recordWithheld(state));
357
- const discussions = failure.family === 'pty-startup' ? PTY_DISCUSSIONS : NATIVE_INIT_DISCUSSIONS;
358
- ctx.logger.warn(`sandbox-grant-advisor: ${failure.family} failure recognized but no advisory sent — ${why}; the raw `
400
+ const discussions = DISCUSSIONS_OF[family];
401
+ ctx.logger.warn(`sandbox-grant-advisor: ${family} failure recognized but no advisory sent — ${why}; the raw `
359
402
  + `error is left exactly as it is, so this is NOT a claim that the sandbox is unrelated (discussions ${discussions})`);
360
403
  return undefined;
361
404
  }
@@ -383,12 +426,20 @@ export function apply(ctx, config = {}) {
383
426
  // read from `result.value` and never from the rendered text, so a command
384
427
  // whose own output mentions the code cannot be mistaken for it.
385
428
  const death = classifyNativeInitDeath(result.value);
386
- if (death === undefined) {
387
- if (previous !== undefined)
388
- states.set(agent, observeSuccess(previous, key));
389
- return undefined;
429
+ if (death !== undefined)
430
+ return adviseGated(agent, previous, death, key, exec.name);
431
+ // The workspace-denial family is the other value-read one, and it is read
432
+ // only when the host is Windows: the mechanism it explains is ACE
433
+ // inheritance, which no other backend has, so on macOS/Linux the same
434
+ // stamp is a different story and is left alone with no note at all (that
435
+ // is the designed denial path there, not a puzzle). The platform test
436
+ // comes first so a non-Windows host never pays for the policy lookup.
437
+ if (process.platform === 'win32' && hasWorkspaceDenialStamp(result.value)) {
438
+ return adviseWorkspaceDenial(agent, previous, result.value, exec.arguments, key, exec.name);
390
439
  }
391
- return adviseGated(agent, previous, death, key, exec.name);
440
+ if (previous !== undefined)
441
+ states.set(agent, observeSuccess(previous, key));
442
+ return undefined;
392
443
  }
393
444
  // The two text families read different fields, on purpose. The ACL signature
394
445
  // carries an API name plus a Win32 code, which a command's own output does
@@ -441,12 +492,12 @@ export function apply(ctx, config = {}) {
441
492
  function adviseGated(agent, previous, failure, key, tool) {
442
493
  const resolution = resolveSandboxMode(ctx, agent);
443
494
  if (!resolution.ok)
444
- return withhold(agent, previous, resolution.withheld, failure);
495
+ return withhold(agent, previous, resolution.withheld, failure.family);
445
496
  const mode = resolution.mode;
446
497
  if (!confines(mode)) {
447
498
  const what = failure.family === 'pty-startup' ? 'the shell' : 'the command';
448
499
  return withhold(agent, previous, `the failing call ran under \`${mode}\`, where ${what} is not spawned `
449
- + 'through the sandbox', failure);
500
+ + 'through the sandbox', failure.family);
450
501
  }
451
502
  if (!claimAdvice(agent, previous, failure, key))
452
503
  return undefined;
@@ -468,6 +519,59 @@ export function apply(ctx, config = {}) {
468
519
  states.set(agent, first ? recordAdvice(advanced, failure.family) : advanced);
469
520
  return first;
470
521
  }
522
+ /**
523
+ * Diagnose one denial whose target lies **inside** the agent's own workspace.
524
+ *
525
+ * The fourth family reads the executor's structured stamp rather than a
526
+ * message, so what is left here is the one fact the stamp cannot carry: the
527
+ * workspace root, which the containment claim has to be tested against. It is
528
+ * resolved from the agent's own session (the same resolver the enforcing
529
+ * providers are handed), and a root that cannot be resolved withholds the
530
+ * advisory rather than guessing — but only after the stamp has been seen, so
531
+ * an ordinary successful call costs no note. Once the root is known the
532
+ * classifier decides; a value that is a denial but names no in-workspace path
533
+ * is the designed escalation path and is left silent on purpose, which is why
534
+ * the `undefined` from the classifier is *not* routed through `withhold`.
535
+ * @param agent - the agent whose call was denied.
536
+ * @param previous - the agent's state before this call, if any.
537
+ * @param value - the settled call's canonical value.
538
+ * @param args - the settled call's parsed arguments.
539
+ * @param key - the identity of the denied call.
540
+ * @param tool - the denied tool's name, for the advisory context.
541
+ * @returns the notice to attach, or undefined.
542
+ */
543
+ function adviseWorkspaceDenial(agent, previous, value, args, key, tool) {
544
+ const resolution = resolveWorkspaceRoot(ctx, agent);
545
+ if (!resolution.ok)
546
+ return withhold(agent, previous, resolution.withheld, 'workspace-denial');
547
+ const failure = classifyWorkspaceDenial(value, args, {
548
+ platform: process.platform,
549
+ workspaceRoot: resolution.workspaceRoot,
550
+ });
551
+ if (failure === undefined)
552
+ return undefined;
553
+ if (!claimAdvice(agent, previous, failure, key))
554
+ return undefined;
555
+ ctx.logger.warn(workspaceDenialHostLine(failure));
556
+ return notice(advisoryText(failure, advisoryContext(tool)), summaryOf(failure));
557
+ }
558
+ /**
559
+ * The one-line host-side account of a recognized workspace-internal denial.
560
+ *
561
+ * It carries the path the plugin keyed on, because this family's whole claim
562
+ * is that one named path lies inside one named root — a maintainer reading the
563
+ * log is entitled to see both halves of the string comparison rather than a
564
+ * verdict about it.
565
+ * @param failure - the recognized failure.
566
+ * @returns a single log line.
567
+ */
568
+ function workspaceDenialHostLine(failure) {
569
+ const subject = failure.paths[0] ?? '<no path recovered>';
570
+ const more = failure.paths.length > 1 ? ` (+${String(failure.paths.length - 1)} more inside the same root)` : '';
571
+ return `sandbox-grant-advisor: confined command denied ${subject}${more}, which is INSIDE the workspace `
572
+ + `${failure.workspaceRoot}, under sandbox mode "${failure.mode}" — the root-only grant skipped this object and `
573
+ + `is never revisited, so retrying cannot help; advisory delivered to the model (discussion ${WORKSPACE_DENIAL_DISCUSSIONS})`;
574
+ }
471
575
  /**
472
576
  * The advisory context for one failing call.
473
577
  *
package/lib/mode.js CHANGED
@@ -19,6 +19,13 @@
19
19
  * session — so what is quoted in the advisory is the policy that actually
20
20
  * governed the failing call, not a guess reconstructed from configuration.
21
21
  *
22
+ * The same lookup answers the **workspace root** for the fourth family
23
+ * (`workspace-denial`, #423), whose claim is that the path a denied command
24
+ * named lies inside the session's own workspace. Same request, same fail-closed
25
+ * posture, separate function ({@link resolveWorkspaceRoot}), so that neither
26
+ * family can fail on a field it never reads and each can name the failure in its
27
+ * own words.
28
+ *
22
29
  * ## Why this is a guarded lookup instead of an import
23
30
  *
24
31
  * `@deepseek-ai/dsh-sandbox-policy` is **optional** in this plugin's world: a
@@ -106,10 +113,60 @@ function recognizedMode(resolved) {
106
113
  * @returns the mode, or the reason it could not be resolved.
107
114
  */
108
115
  export function resolveSandboxMode(ctx, agent) {
116
+ const lookup = policyOf(ctx, agent);
117
+ if (!lookup.ok)
118
+ return { ok: false, withheld: lookup.withheld };
119
+ const mode = recognizedMode(lookup.resolved);
120
+ if (mode === undefined) {
121
+ return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a recognizable mode' };
122
+ }
123
+ return { ok: true, mode };
124
+ }
125
+ /**
126
+ * The workspace root the policy resolver reports for one agent's call.
127
+ *
128
+ * The same lookup, the same request and the same fail-closed posture as
129
+ * {@link resolveSandboxMode}, and for the same reason: the root is what the
130
+ * enforcing providers were handed, so it is the value the workspace-denial
131
+ * family must test containment against rather than one reconstructed from
132
+ * configuration. The two are separate functions rather than one that returns
133
+ * both because each family needs one of them, and a family that needs only the
134
+ * mode must not be able to fail on a missing root (nor the other way round).
135
+ * @param ctx - the plugin's context.
136
+ * @param agent - the agent whose call failed.
137
+ * @returns the root, or the reason it could not be resolved.
138
+ */
139
+ export function resolveWorkspaceRoot(ctx, agent) {
140
+ const lookup = policyOf(ctx, agent);
141
+ if (!lookup.ok) {
142
+ return {
143
+ ok: false,
144
+ withheld: lookup.why === 'unmounted'
145
+ ? 'no `sandboxPolicy` service is mounted in this composition, so the workspace root is unknown'
146
+ : lookup.withheld,
147
+ };
148
+ }
149
+ const resolved = lookup.resolved;
150
+ const root = resolved === null || typeof resolved !== 'object'
151
+ ? undefined
152
+ : resolved.workspaceRoot;
153
+ if (typeof root !== 'string' || root.length === 0) {
154
+ return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a usable workspace root' };
155
+ }
156
+ return { ok: true, workspaceRoot: root };
157
+ }
158
+ /**
159
+ * Ask the mounted policy service for one agent's resolved policy.
160
+ * @param ctx - the plugin's context.
161
+ * @param agent - the agent whose call is being placed.
162
+ * @returns the resolver's answer, or the reason there is none.
163
+ */
164
+ function policyOf(ctx, agent) {
109
165
  const policy = asPolicy(ctx.get('sandboxPolicy'));
110
166
  if (policy === undefined) {
111
167
  return {
112
168
  ok: false,
169
+ why: 'unmounted',
113
170
  withheld: 'no `sandboxPolicy` service is mounted in this composition, so the effective mode is unknown',
114
171
  };
115
172
  }
@@ -117,19 +174,14 @@ export function resolveSandboxMode(ctx, agent) {
117
174
  if (session === undefined) {
118
175
  return {
119
176
  ok: false,
177
+ why: 'no-session',
120
178
  withheld: 'the agent exposes no session, and the policy must be resolved from it rather than from the deployment default',
121
179
  };
122
180
  }
123
- let resolved;
124
181
  try {
125
- resolved = policy.resolve({ session });
182
+ return { ok: true, resolved: policy.resolve({ session }) };
126
183
  }
127
184
  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' };
185
+ return { ok: false, why: 'threw', withheld: `\`sandboxPolicy.resolve\` threw (${String(error)})` };
133
186
  }
134
- return { ok: true, mode };
135
187
  }
package/lib/signature.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Recognize the three environment failures this plugin explains, and refuse
2
+ * Recognize the four environment failures this plugin explains, and refuse
3
3
  * everything else.
4
4
  *
5
5
  * ## The ACL provisioning failure (`acl-provisioning`)
@@ -147,6 +147,82 @@
147
147
  * observe and would make this family untestable on the host this plugin is
148
148
  * built on — which is how a family ships without ever having been run.
149
149
  *
150
+ * ## Denied inside the workspace (`workspace-denial`)
151
+ *
152
+ * The fourth family is the other half of the same backend the first one
153
+ * explains. There the grant could not be applied at all and every command died
154
+ * before it ran; here the grant *was* applied and the workspace looks
155
+ * provisioned — and part of the tree still refuses writes. `#423` is the
156
+ * report: under `workspace-write` on Windows, a command writing into a
157
+ * subdirectory that was created or moved in from outside the session is denied,
158
+ * forever, while the same command against a directory the harness itself
159
+ * created succeeds.
160
+ *
161
+ * Like the native-init family, and for the same structural reason, this one is
162
+ * **invisible from the error path**: a denied command exits nonzero, and the
163
+ * shipped shell tools report a nonzero exit as a finished run rather than as
164
+ * `isError`, so the fact arrives in `ToolExecutionSuccess.value`. Unlike that
165
+ * family it does not need a bespoke code to be read — the shipped executors
166
+ * *stamp the denial* onto the value, as a structured triple no line of rendered
167
+ * text can fabricate:
168
+ *
169
+ * `sandbox: { mode, denied: true, enforcement? }`
170
+ *
171
+ * (`packages/shell/bash-sandbox/src/index.ts` and `pwsh-sandbox`, both on
172
+ * `classifyDenial`; projected into the tool result value by `tool-bash` /
173
+ * `tool-pwsh`). `denied` is produced by matching the backend's own refusal
174
+ * dialect in the *captured stderr* — `'access is denied'`, `'access to the
175
+ * path'`, `'permission denied'`, `'operation not permitted'` for the
176
+ * `windows-acl` backend — so the value is the harness's reading of its own
177
+ * sandbox, not this plugin's reading of a message.
178
+ *
179
+ * **`denied` alone is not this family, and the narrowings are the whole
180
+ * design.** A denial is the *sanctioned* outcome in three other situations, and
181
+ * advising about a missing inherited grant in any of them would be the
182
+ * confidently wrong cause this module exists to avoid:
183
+ *
184
+ * - **Outside the workspace.** `fs-sandbox` throws its marker only when a path
185
+ * falls outside the writable roots, and a confined shell denying a path
186
+ * outside the workspace is the designed escalation path — the denial surface
187
+ * offers one retry under a wider mode, and that offer is correct there.
188
+ * - **Under `read-only`.** That mode denies *every* write by construction
189
+ * (`fs-sandbox`'s `checkedTarget`: `read-only` throws before any containment
190
+ * question is asked), so an in-workspace denial under it is the mode working.
191
+ * - **A runner failure.** The executor refuses to call a run denied when the
192
+ * runner itself failed (`denied: !runnerFailed && …`), and a value that
193
+ * carries `runnerFailed: true` is left alone for the same reason.
194
+ *
195
+ * What is left is exactly the anomaly: **a denial under `workspace-write` of a
196
+ * path inside the session's own workspace**, which is the one combination the
197
+ * harness is supposed to make impossible. The mode comes off the value (the
198
+ * executor stamped the mode it actually ran under, so no policy lookup can
199
+ * disagree with it), and the containment test needs the workspace root, which
200
+ * comes from the policy resolver at the call site —
201
+ * {@link classifyWorkspaceDenial} takes it as a fact rather than reading it, so
202
+ * the recognition stays a pure function of its inputs.
203
+ *
204
+ * **The in-workspace half of that test is keyed on the command's own text**, and
205
+ * deliberately not on the stderr the denial was inferred from: the regex looks
206
+ * for drive-qualified or UNC path literals in the call's `command` argument, and
207
+ * the family is recognized only when **every** such literal it finds lies under
208
+ * the workspace root. A command that names an outside path as well — an
209
+ * interpreter shipped under `C:\Program Files`, an output directory on another
210
+ * volume — is refused rather than guessed at, and so is a command that names
211
+ * only relative paths: in both cases the plugin cannot say *which* path was
212
+ * denied, and silence is the fail-closed direction. The paths it did key on are
213
+ * carried into the advisory, so the reader can see the reasoning rather than
214
+ * take it on faith.
215
+ *
216
+ * **The platform gate is real here, and unlike the native-init family it cannot
217
+ * be dropped.** The mechanism is Windows ACE inheritance: the backend writes the
218
+ * grant once, on the workspace root, and relies on the operating system to
219
+ * propagate it to descendants, which needs `WRITE_DAC` on each descendant at
220
+ * that moment. Every other backend applies its policy per call to the process
221
+ * (landlock rules, a bwrap profile, a seatbelt profile) and has no descendant to
222
+ * miss, so the same value on those hosts is a different story. `win32` is
223
+ * therefore part of recognition, passed in rather than read, and the suite runs
224
+ * this family with the platform fact supplied.
225
+ *
150
226
  * @module
151
227
  */
152
228
  /** The exact text `dsh-terminal-bash` throws when the shell exits during startup. */
@@ -163,6 +239,16 @@ export const PTY_STARTUP_EXIT = 'PTY shell exited during startup';
163
239
  export const STATUS_DLL_INIT_FAILED = 0xC0000142;
164
240
  /** The `kind` discriminator the shipped shell tools put on a finished foreground run. */
165
241
  const FOREGROUND = 'foreground';
242
+ /**
243
+ * The mode in which a write **inside** the workspace is supposed to succeed.
244
+ *
245
+ * It is the only mode this family is recognized under, and that is a fact about
246
+ * the other two rather than about this one: `read-only` denies every write by
247
+ * construction, and `danger-full-access` spawns nothing through the sandbox at
248
+ * all — so a denial under either is the mode doing its job, and the one place a
249
+ * denial is an anomaly is here.
250
+ */
251
+ export const DENIAL_MODE = 'workspace-write';
166
252
  /**
167
253
  * The producer's format is fixed by `Win32Error`
168
254
  * (`packages/subprocess/win32-process/src/errors.ts`):
@@ -273,6 +359,121 @@ export function failureLine(failure) {
273
359
  return failure.line;
274
360
  if (failure.family === 'native-init')
275
361
  return `[exit code: ${String(failure.rawExitCode)}]`;
362
+ if (failure.family === 'workspace-denial') {
363
+ const status = failure.exitCode === null ? '' : `[exit code: ${String(failure.exitCode)}] `;
364
+ return `${status}sandbox: { mode: "${failure.mode}", denied: true }`;
365
+ }
276
366
  const suffix = failure.detail.length === 0 ? '' : `: ${failure.detail}`;
277
367
  return `${failure.api} failed (Win32 ${failure.win32Code})${suffix}`;
278
368
  }
369
+ /**
370
+ * The drive-qualified and UNC path literals in one command line.
371
+ *
372
+ * Deliberately crude, in the direction that keeps the diagnosis honest. A
373
+ * literal is taken to end at the first character a shell token cannot carry
374
+ * unquoted, so a path containing a space is recovered as its first segment — a
375
+ * **prefix** of the real path, which preserves the containment answer for every
376
+ * path that lies under the root (a prefix of a descendant is still a descendant)
377
+ * and refuses the test for paths whose root itself contains a space. A path that
378
+ * appears twice is returned once.
379
+ * @param command - the `command` argument of the failing shell call.
380
+ * @returns the literals found, in order, without trailing separators.
381
+ */
382
+ export function windowsPathsIn(command) {
383
+ const found = [];
384
+ for (const match of command.matchAll(/(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`|<>;&,]*/g)) {
385
+ const literal = match[0].replace(/[\\/]+$/, '');
386
+ if (literal.length >= 3 && !found.includes(literal))
387
+ found.push(literal);
388
+ }
389
+ return found;
390
+ }
391
+ /**
392
+ * Whether one path lies at or under one root, the way Windows compares them.
393
+ * @param root - the workspace root.
394
+ * @param path - the candidate path.
395
+ * @returns true when `path` is `root` itself or a descendant of it.
396
+ */
397
+ export function isInsideWorkspace(root, path) {
398
+ const canon = (value) => value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase();
399
+ const base = canon(root);
400
+ const target = canon(path);
401
+ if (base.length === 0 || target.length === 0)
402
+ return false;
403
+ return target === base || target.startsWith(`${base}\\`);
404
+ }
405
+ /**
406
+ * Whether one settled value carries the executors' workspace-denial stamp.
407
+ *
408
+ * The **structural half** of the fourth family's recognition, split out so that
409
+ * "this is not a denial at all" (silent, and the overwhelming majority of
410
+ * successful calls) stays distinguishable from "this is a denial and the plugin
411
+ * could not finish placing it" — the case the disclosure rule requires the host
412
+ * log to account for. It reads the value the executors wrote, never a line of
413
+ * rendered text, and it is deliberately platform-blind: the platform gate is a
414
+ * fact the classifier takes as an argument, and a stamp test that baked it in
415
+ * could not be used to decide whether a missing platform fact is worth saying
416
+ * out loud.
417
+ *
418
+ * What it does **not** decide is which path was denied — that needs the call's
419
+ * arguments and the workspace root, and belongs to
420
+ * {@link classifyWorkspaceDenial}. A stamp alone is the sanctioned outcome in
421
+ * three other situations (outside the workspace, under `read-only`, a runner
422
+ * failure); this function excludes the third by itself and leaves the first two
423
+ * to the classifier, which is the only place they can be separated.
424
+ * @param value - a settled execution's canonical value.
425
+ * @returns true when the value is a foreground shell projection stamped
426
+ * `denied: true` under `workspace-write` with no runner failure.
427
+ */
428
+ export function hasWorkspaceDenialStamp(value) {
429
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
430
+ return false;
431
+ const probe = value;
432
+ if (probe.kind !== FOREGROUND)
433
+ return false;
434
+ const sandbox = probe.sandbox;
435
+ if (sandbox === null || typeof sandbox !== 'object')
436
+ return false;
437
+ const { mode, denied, runnerFailed } = sandbox;
438
+ return mode === DENIAL_MODE && denied === true && runnerFailed !== true;
439
+ }
440
+ /**
441
+ * Classify one **successful** execution's canonical value as a workspace-internal
442
+ * denial, and recover the paths the call named.
443
+ *
444
+ * Four facts, in the order they narrow: the call ran on Windows (the mechanism
445
+ * is ACE inheritance, which no other backend has); the value carries the
446
+ * executor's denial stamp ({@link hasWorkspaceDenialStamp}); the call's
447
+ * `command` argument carries at least one absolute Windows path literal; and
448
+ * **all** of them lie under the workspace root. The last is the one that
449
+ * separates this family from the sanctioned escalation path — a command naming
450
+ * any path outside its own workspace is refused rather than guessed at, because
451
+ * the plugin cannot say which path the sandbox refused.
452
+ * @param value - the settled execution's canonical value (`ToolExecutionSuccess.value`).
453
+ * @param args - the settled call's parsed arguments (`ToolExecution.arguments`).
454
+ * @param facts - the host platform and the session's workspace root.
455
+ * @returns the recognized failure, or undefined when this is not one.
456
+ */
457
+ export function classifyWorkspaceDenial(value, args, facts) {
458
+ if (facts.platform !== 'win32')
459
+ return undefined;
460
+ if (!hasWorkspaceDenialStamp(value))
461
+ return undefined;
462
+ const probe = value;
463
+ const command = args?.command;
464
+ if (typeof command !== 'string')
465
+ return undefined;
466
+ const named = windowsPathsIn(command);
467
+ if (named.length === 0)
468
+ return undefined;
469
+ if (!named.every(path => isInsideWorkspace(facts.workspaceRoot, path)))
470
+ return undefined;
471
+ const exitCode = typeof probe.exitCode === 'number' && Number.isInteger(probe.exitCode) ? probe.exitCode : null;
472
+ return {
473
+ family: 'workspace-denial',
474
+ mode: DENIAL_MODE,
475
+ exitCode,
476
+ paths: named,
477
+ workspaceRoot: facts.workspaceRoot,
478
+ };
479
+ }