@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/README.md +287 -24
- package/cordis.patch.yml +82 -4
- package/lib/advice.js +322 -12
- package/lib/index.js +128 -24
- package/lib/mode.js +60 -8
- package/lib/signature.js +202 -1
- package/lib/types/advice.d.ts +107 -9
- package/lib/types/index.d.ts +48 -9
- package/lib/types/mode.d.ts +34 -0
- package/lib/types/signature.d.ts +189 -3
- package/package.json +2 -2
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
|
|
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.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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**.
|
|
125
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
350
|
-
*
|
|
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,
|
|
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 =
|
|
358
|
-
ctx.logger.warn(`sandbox-grant-advisor: ${
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|