@argszero/cordis-plugin-sandbox-grant-advisor 0.10.0 → 0.12.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 +192 -25
- package/cordis.patch.yml +46 -2
- package/lib/advice.js +198 -8
- package/lib/index.js +128 -24
- package/lib/mode.js +60 -8
- package/lib/signature.js +202 -1
- package/lib/types/advice.d.ts +56 -3
- 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/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
|
+
}
|
package/lib/types/advice.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* environment failure, and what is deliberately withheld.
|
|
4
4
|
*
|
|
5
5
|
* The text is assembled here as pure functions so every sentence can be pinned
|
|
6
|
-
* by a test, one family at a time. The
|
|
6
|
+
* by a test, one family at a time. The four families are shaped by the same
|
|
7
7
|
* question — is this the sandbox's doing, and what can the reader do about it —
|
|
8
8
|
* and they answer it differently:
|
|
9
9
|
*
|
|
@@ -112,6 +112,31 @@
|
|
|
112
112
|
* want of that variable. A withdrawn cause earns its sentence because this
|
|
113
113
|
* advisory shipped it twice; a confidently wrong cause is worse than two named
|
|
114
114
|
* ones with one shared remedy.
|
|
115
|
+
* - **The workspace-internal denial** (`workspace-denial`, #423) is the fourth,
|
|
116
|
+
* and it is the first whose remedy is **withheld on purpose**. The failure is
|
|
117
|
+
* not that a command failed but that the workspace's own grant does not cover
|
|
118
|
+
* part of the workspace: the grant is written once on the root and depends on
|
|
119
|
+
* ACE inheritance, so an already-existing object whose DACL the caller could
|
|
120
|
+
* not write kept its older DACL, and the backend's root-only `hasExactGrant`
|
|
121
|
+
* check means it is never revisited. Retrying is therefore provably useless,
|
|
122
|
+
* which is what the text leads with. What it does **not** do is print a repair
|
|
123
|
+
* command: the obvious one (`icacls` recursing the capability SID) is refused
|
|
124
|
+
* by Windows itself with `ERROR_NONE_MAPPED` (1332), because that SID has no
|
|
125
|
+
* name to map, and the line that would work needs the right that is missing —
|
|
126
|
+
* so the advisory states the mechanism, names the ceiling, and says out loud
|
|
127
|
+
* that it prints no command because this project has no Windows host to verify
|
|
128
|
+
* one on. That is the same standard the standing-grant section is held to, and
|
|
129
|
+
* it is the reason this family can be useful without being prescriptive.
|
|
130
|
+
* Its discriminator is the **two-sided** one, which is not obvious and which a
|
|
131
|
+
* reader checking only the ACE would get wrong: reading and listing use the
|
|
132
|
+
* normal token while writing and deleting use the restricted one, so an object
|
|
133
|
+
* whose DACL names only `Administrators`/`SYSTEM` plus the capability SID is
|
|
134
|
+
* refused on the read side too and looks granted to a grep for the SID
|
|
135
|
+
* (#423 measured it). The variant that would otherwise be missed gets its own
|
|
136
|
+
* sentence as well — the same shape with the missing coverage on the mandatory
|
|
137
|
+
* label instead of the DACL (reported 2026-09-29 in the same thread), which is
|
|
138
|
+
* indistinguishable from inside a session and separable by the repository's own
|
|
139
|
+
* diagnosis skill.
|
|
115
140
|
*
|
|
116
141
|
* Both give a **discriminator, not just a remedy**: applying a fix without
|
|
117
142
|
* confirming the cause teaches nothing when the fix does not work. For the ACL
|
|
@@ -126,10 +151,16 @@
|
|
|
126
151
|
* wrong remedy. Inside that Electron answer there is a third thing the code
|
|
127
152
|
* cannot separate, and the advisory deliberately does not try: it names both
|
|
128
153
|
* measurements and says the remedy does not depend on choosing between them.
|
|
154
|
+
* For the workspace-denial family the discriminator is the **path**, which is
|
|
155
|
+
* why the advisory prints the path it keyed on beside the root it tested it
|
|
156
|
+
* against: the reader can audit the plugin's own reasoning instead of taking a
|
|
157
|
+
* claim about two strings on faith. That family's check is also the one place
|
|
158
|
+
* where a single fact is not enough — reachability has two sides, and the text
|
|
159
|
+
* says which one a check on the ACE alone would miss.
|
|
129
160
|
*
|
|
130
161
|
* @module
|
|
131
162
|
*/
|
|
132
|
-
import type { ProvisioningFailure, RecognizedFailure } from './signature.js';
|
|
163
|
+
import type { FailureFamily, ProvisioningFailure, RecognizedFailure } from './signature.js';
|
|
133
164
|
import type { SandboxModeName } from './mode.js';
|
|
134
165
|
/**
|
|
135
166
|
* The upstream threads the ACL advisory is a stopgap for.
|
|
@@ -159,7 +190,29 @@ export declare const PTY_DISCUSSIONS = "#7638 / #8322";
|
|
|
159
190
|
* launched from a terminal does not, which is the same variable the PTY family
|
|
160
191
|
* now names.
|
|
161
192
|
*/
|
|
162
|
-
export declare const NATIVE_INIT_DISCUSSIONS = "#7876 / #7877 / #8193 / #8208 / #8313";
|
|
193
|
+
export declare const NATIVE_INIT_DISCUSSIONS = "#7876 / #7877 / #8193 / #8208 / #8313 / #8336 / #8334";
|
|
194
|
+
/**
|
|
195
|
+
* The upstream thread the workspace-denial advisory is a stopgap for.
|
|
196
|
+
*
|
|
197
|
+
* One thread, because this family is one report and its own follow-up: `#423`
|
|
198
|
+
* is the failure and its measurements (170 of 729 objects missing the grant,
|
|
199
|
+
* root-level files among them, and the two-sided reachability rule), and the
|
|
200
|
+
* second comment in that thread is the mandatory-label variant of the same
|
|
201
|
+
* shape.
|
|
202
|
+
*/
|
|
203
|
+
export declare const WORKSPACE_DENIAL_DISCUSSIONS = "#423";
|
|
204
|
+
/**
|
|
205
|
+
* The thread list each family's withholding note cites.
|
|
206
|
+
*
|
|
207
|
+
* A withheld recognition is a decision the host log has to account for, and the
|
|
208
|
+
* note a maintainer reads is only useful if it points at *that* family's
|
|
209
|
+
* report — a withheld native-init death and a withheld workspace denial are
|
|
210
|
+
* different reports, and a note that cites the wrong one is its own small
|
|
211
|
+
* misdiagnosis. Kept beside the constants it assembles rather than at the
|
|
212
|
+
* withholding site, so a family added without a thread is a type error instead
|
|
213
|
+
* of a note that quietly cites another family's.
|
|
214
|
+
*/
|
|
215
|
+
export declare const DISCUSSIONS_OF: Record<FailureFamily, string>;
|
|
163
216
|
/** The documented prerequisite, quoted from the backend's README. */
|
|
164
217
|
export declare const PREREQUISITE = "granted directories must be caller-owned and grant `WRITE_OWNER`";
|
|
165
218
|
/**
|
package/lib/types/index.d.ts
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,7 +190,12 @@
|
|
|
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
|
*/
|
package/lib/types/mode.d.ts
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
|
|
@@ -73,6 +80,18 @@ export type ModeResolution =
|
|
|
73
80
|
readonly ok: false;
|
|
74
81
|
readonly withheld: string;
|
|
75
82
|
};
|
|
83
|
+
/** The outcome of resolving one agent's workspace root. */
|
|
84
|
+
export type RootResolution =
|
|
85
|
+
/** The workspace root the enforcing providers were handed. */
|
|
86
|
+
{
|
|
87
|
+
readonly ok: true;
|
|
88
|
+
readonly workspaceRoot: string;
|
|
89
|
+
}
|
|
90
|
+
/** No answer was available, and why — the host side says so out loud. */
|
|
91
|
+
| {
|
|
92
|
+
readonly ok: false;
|
|
93
|
+
readonly withheld: string;
|
|
94
|
+
};
|
|
76
95
|
/**
|
|
77
96
|
* Resolve the effective sandbox mode for one agent's call.
|
|
78
97
|
*
|
|
@@ -85,3 +104,18 @@ export type ModeResolution =
|
|
|
85
104
|
* @returns the mode, or the reason it could not be resolved.
|
|
86
105
|
*/
|
|
87
106
|
export declare function resolveSandboxMode(ctx: Context, agent: Agent): ModeResolution;
|
|
107
|
+
/**
|
|
108
|
+
* The workspace root the policy resolver reports for one agent's call.
|
|
109
|
+
*
|
|
110
|
+
* The same lookup, the same request and the same fail-closed posture as
|
|
111
|
+
* {@link resolveSandboxMode}, and for the same reason: the root is what the
|
|
112
|
+
* enforcing providers were handed, so it is the value the workspace-denial
|
|
113
|
+
* family must test containment against rather than one reconstructed from
|
|
114
|
+
* configuration. The two are separate functions rather than one that returns
|
|
115
|
+
* both because each family needs one of them, and a family that needs only the
|
|
116
|
+
* mode must not be able to fail on a missing root (nor the other way round).
|
|
117
|
+
* @param ctx - the plugin's context.
|
|
118
|
+
* @param agent - the agent whose call failed.
|
|
119
|
+
* @returns the root, or the reason it could not be resolved.
|
|
120
|
+
*/
|
|
121
|
+
export declare function resolveWorkspaceRoot(ctx: Context, agent: Agent): RootResolution;
|