Skip to content

Commit 045cfa4

Browse files
fix(runtime): an action or job body refused for a missing capability answers the same coded 403 a hook body does, not a bare 500 (#27045)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQ89qrdULQuk8UsPzHSD5UT)_ Fixes #26965 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:action 或 job 的沙箱 body 调用了没授予的能力(如没声明 `api.read` 却调 `ctx.api.object('x').find`)时,`POST /api/v1/actions/对象/动作` 不再回 `500 Internal server error`,而是和 hook 一样回 `403 INSUFFICIENT_PRIVILEGES`,消息点名是哪个 action/job、缺哪个能力,并提示加到「该 action/job 自己的」`body.capabilities`。 - 为什么改:#26963 之后同一道能力门对 hook 回 403、对 action 回 500,一道门两种答案;分诊裁决 A:一道门一个答案。 - 风险与回滚:能力检查本身一点没放松,被拒的调用照样不执行、不写数据;只改拒绝的作答方式。job 没有 HTTP 调用方,运行记录的 `error` 存这句话,error 日志行保留给运维。回滚即 revert 本 PR。 - 先看:`packages/runtime/src/sandbox/quickjs-runner.ts` 的 `throwCapabilityDenial`;`action-job-capability-denial-is-a-refusal.integration.test.ts`;`packages/core/src/utils/import-runner-sandbox-fault-row.test.ts` 的 §4。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/sandbox/action-job-capability-denial-is-a-refusal.integration.test.ts`,预期 16 项全过(5 个能力 × action / job,加对照)。 - 需要你做的:无,席位落地。 ## What changes - **`@objectstack/runtime`**, `quickjs-runner.ts`, `throwCapabilityDenial`: the first statement `if (origin.kind !== 'hook') throwSandboxFault(vm, message);` is gone, so every origin's denial crosses the VM as the coded refusal the hook arm already raised (`code: 'INSUFFICIENT_PRIVILEGES'`, `status: 403`). The remedy names the owner from the origin, in that one template: `Grant it by adding 'api.read' to the action's body.capabilities.` (or `hook's`, `job's`). No per-origin builder; the two constants lose their `HOOK_` prefix (`CAPABILITY_REFUSAL_CODE` / `_STATUS`, module-private). The capability check itself is untouched. - Docblocks that said a capability denial is the sandbox's own fault (`SandboxError.innerMessage`, `SANDBOX_FAULT_PROP`, `ErrorInfo.sandboxFault`, the pump loop's fault branch) now say it is a refusal for every origin; the fault marker keeps the other host-side faults. - **Tests (runtime):** - `capability-denial-is-a-fault.test.ts` is renamed `capability-denial-is-a-refusal.test.ts` and its pin flipped: each in-VM gate under an action origin hands the classifier `innerMessage` = the refusal sentence (no `threw:` wrapper, no `SandboxError:` prefix), `code: INSUFFICIENT_PRIVILEGES`, `status: 403`; one new case for a job origin. The "denial the body catches and rethrows" and rejection-side cases are unchanged. - New `action-job-capability-denial-is-a-refusal.integration.test.ts` (16): a real `ObjectQL` on `SqlDriver` (better-sqlite3), the real `QuickJSScriptRunner`, the real `actionBodyRunnerFactory` / `jobBodyRunnerFactory`, and the real `/actions` door (`HttpDispatcher.handleActions`). Per token: the action answers `403 INSUFFICIENT_PRIVILEGES` naming action, capability and remedy, nothing written, one `info` refusal line and no `error` line; the job rejects with the same coded refusal and keeps its `error` line; controls with the capability run. - `nested-hook-refusal-is-a-rejection.test.ts`: the "capability denial inside the action body is still a fault" case is flipped to the `403` through the real door. - `actions-fault-vs-rejection.test.ts` and three docblocks: wording only (a timeout is still the sandbox's own fault; a capability denial is no longer named as one). - **Census (`@objectstack/core` `test:repo`):** `import-runner-sandbox-fault-row.test.ts` is migrated to the new rule, still derived from the producer and with no hand-written site list. The `throwCapabilityDenial` call sites leave the fault population (the `capability denial` fault kind is removed, and §1 now asserts no fault site reads as a capability denial). The helper is read from its body: it must not reach `throwSandboxFault`, its `new Error(…)` template is rendered per gate, and its `code` / `status` are read from the `Object.assign` (resolving the module constants). New §4 renders every gate under each origin kind (`hook`, `action`, `job`) and holds the door and the import row. - **Dogfood:** new `action-capability-refusal.dogfood.test.ts` (2) through the real stack's `POST /api/v1/actions/acr_note/ACTION`. - Changeset: `@objectstack/runtime` patch. ## Why Triage ruling A on #26965 (`6456639416`): one gate, one answer. After #26963 a hook body's capability denial answered `403 INSUFFICIENT_PRIVILEGES` naming the hook and the capability, while an action body's same call answered `500 INTERNAL_ERROR` "Internal server error", telling neither the caller nor the author which action or which capability. The #4431 argument (an action's own denial is a bug for the operator) was set aside for hooks on #26903 and nothing in it is specific to hooks. The operator signal stays server-side: an action's refusal is logged once at `info` by the BodyRunner (`[BodyRunner] sandboxed action refused`, with code and status) and the `/actions` door's `console.error` line keeps the full wrapper; a job keeps its `error` line. ## Acceptance notes - **Import row, measured (census §4).** A coded 403 stays a row error: `{ ok: false, action: 'failed', error: SENTENCE (the refusal sentence), code: 'INSUFFICIENT_PRIVILEGES' }`. Before, the action-origin rendering the census held read `error: 'Internal server error'`, `code: 'IMPORT_ROW_FAILED'`. The `ctx.api.object('OBJ')` gate names the import's own object, and the door answers it `403`, not the unknown-object heuristic's `404` (`mapDataError` reads the refusal's code before any message limb). An import fires hooks only, so in practice this is the hook row #26963 already produced; the census now pins it. - **Job.** A job reaches no HTTP caller. Its handler rejects with the coded refusal; `DbJobAdapter` stores `err.message` as the run's `error` (`db-job-adapter.ts`, `finishRun(runId, 'failed', …)`), which now reads `job 'J' threw: SandboxError: capability 'X' not granted to job 'J'. Grant it by adding 'X' to the job's body.capabilities.` The BodyRunner's `[BodyRunner] sandboxed job threw` line stays at `error`. - **`Clause-②: no`, measured.** No call that ran before is refused: the denied call was refused before and is refused now (the gate is the same code path), and every control with the capability passes. What moves is the answer for an already-refused call: `500 INTERNAL_ERROR` becomes `403 INSUFFICIENT_PRIVILEGES`, and the log-only `.message` gains the `action 'X' threw:` wrapper the refusal relay uses. - **Overlap with #26831** (open, touches `quickjs-runner.ts`): `git merge-tree --write-tree` of this head with its head `a1dbd6ea` exits 0 with no conflict. Its catalogue resolver is hook-only and passes host-relayed errors through, so an action or job denial does not meet it. - **Comments outside the claim's file surface still say a capability denial is the sandbox's own fault** (stale since #26963 for hooks, and now for every origin): `packages/types/src/data-error-classification.ts` (~786–792, ~1382), `packages/core/src/utils/import-runner.ts` ~522, `packages/rest/src/error-response.ts` ~708, `external-datasource-routes.ts` ~450, `package-routes.ts` ~239, `sharing-routes.ts` ~429. `packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts` keeps a hand-built `sandboxOwnFault("capability 'api.read' not granted …")` row; it still passes because it pins the classifier on a synthesised shape, which the producer no longer emits for a capability denial. Comments and a synthetic row only, no behaviour; not touched here (`carrier: none`). - The console's display of the 403 is #26726's, not this card's. ## Verification **At head `af5816664b`**, branched from `origin/main` `4bce21da` (which carries #26963 as `e46e25c0`). Every build and test ran under `os-verify-lock`. - **Red first** (the new integration file, before the fix, on `4bce21da`): 10 failed, 6 passed of 16. The 10 are exactly the five action and five job denials; the action arm answered `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error","httpStatus":500}}: expected 500 to be 403`, the job arm `expected undefined to be 'INSUFFICIENT_PRIVILEGES'`. The six controls passed. - **Ablation** (the fix committed first; `scripts/ablation-replace.mjs` put the removed `if (origin.kind !== 'hook') throwSandboxFault(vm, message);` back, runtime rebuilt, `ablation-dist-preflight` confirmed the marker in `dist/`; restored to the HEAD blob `e31f4067`, `git diff HEAD` empty, rebuilt, preflight `--absent` confirmed): - runtime pins (the new integration file, the flipped `capability-denial-is-a-refusal.test.ts`, `nested-hook-refusal-is-a-rejection.test.ts`): 16 failed, 13 passed of 29; the 16 are every denial case. - dogfood file: 1 failed, 1 passed (`expected 500 to be 403`, the body `Internal server error`); restored: 2 of 2. - core census: 1 failed of 40 (`throwCapabilityDenial reaches throwSandboxFault: expected true to be false`). - `pnpm --filter @objectstack/runtime test`: 466 files, 7597 passed, 19 skipped. `pnpm --filter @objectstack/runtime typecheck`: exit 0 (tsc + `check:test-typecheck` OK). - `vitest run --project repo` for every package with a `vitest.repo-tests.json` except spec: core 94, objectql 5, rest 280 (18 skipped), runtime 948, types 11, all passed. Spec's repo project (about 16 min) is left to CI's Test Core. - `pnpm --filter @objectstack/dogfood exec vitest run test/action-capability-refusal.dogfood.test.ts`: 2 of 2, after building the dogfood closure. Live log line from it: `INFO [BodyRunner] sandboxed action refused {"action":"acr_count_denied","code":"INSUFFICIENT_PRIVILEGES","status":403,...}`. - **Dispatch gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 families on this diff; all 66 were run with each exit code captured before any pipe, and all 66 exited 0. Two first exited 3 (PREREQUISITE NOT MET, nothing measured): `check-plugin-teardown-shape.mjs --self-test` on the shallow clone (fetched its pinned fixture commit, then 48 cases pass) and `check:dual-build-cjs-loads` before a full `pnpm build` (built, then 112 entry points across 69 packages load). `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, exit 0. - `eslint --no-inline-config` on `packages/runtime`, the census file and the new dogfood file: exit 0. `check-commit-card-trailers --range origin/main..HEAD`: exit 0. `check-platform-checklist --changed-from origin/main`: exit 0 (no anchored files). - Dogfood: no full run owed (no `!`, no narrowing arm). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5opKoaTFmNRbYvKobt --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5opKoaTFmNRbYvKobt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbc1656 commit 045cfa4

10 files changed

Lines changed: 576 additions & 167 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): an action or job body refused for a missing capability answers the same coded 403 a hook body does, not a bare 500 (#26965)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
**What was wrong.** Since #26903 a hook body that calls something its `body.capabilities` does not grant is refused `403 INSUFFICIENT_PRIVILEGES`, naming the hook, the capability and the remedy. The same call from an action body (for example `ctx.api.object('note').find(...)` without `api.read`) still answered `POST /api/v1/actions/OBJECT/ACTION` with `500 INTERNAL_ERROR` "Internal server error", so the caller and the author were not told which action or which capability. A job body's denial was the same bare fault.
11+
12+
**What changes.** One gate, one answer. An action or job body's capability denial is the same refusal: `403 INSUFFICIENT_PRIVILEGES`, an existing standard catalog code, with a sentence such as `capability 'api.read' not granted to action 'note_count' (called ctx.api.object('note').find). Grant it by adding 'api.read' to the action's body.capabilities.` The remedy names the body's own owner (the hook's, the action's or the job's `body.capabilities`). The capability check is exactly as strict as before; nothing the body asked for runs. An action's refusal is logged once at `info`; a job reaches no HTTP caller, so its run records this sentence and its `error` log line stays.

‎packages/core/src/utils/import-runner-sandbox-fault-row.test.ts‎

Lines changed: 148 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,18 @@
5656
* names the requested object to the door's `500`, and §3 holds the controls
5757
* that keep the heuristic's other answers where they were.
5858
*
59-
* [#26903] The capability gates call `throwCapabilityDenial(vm, origin,
60-
* capability, message)`, not `throwSandboxFault` directly. Each of those calls
61-
* is still a site here, its message rendered from argument 3, but with an
62-
* ACTION origin: the helper sends an action or job body's denial to
63-
* `throwSandboxFault` (the fault this file pins) and answers a HOOK body's as
64-
* a coded `403` refusal, which is not a fault and is pinned by
65-
* `runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts`.
66-
* The census reads that routing from the helper's body rather than trusting
67-
* this comment: if its first statement stops sending every non-hook origin to
68-
* `throwSandboxFault`, the census is red. The helper's own
69-
* `throwSandboxFault(vm, message)` relays its call sites, like the
70-
* `new SandboxError(message)` inside `throwSandboxFault`.
59+
* [#26903/#26965] The capability gates call `throwCapabilityDenial(vm, origin,
60+
* capability, message)`, not `throwSandboxFault`. Since #26965 that helper
61+
* answers EVERY origin's denial (hook, action, job) as a coded refusal, so a
62+
* capability gate is no longer a fault site: §4 holds each one to the refusal
63+
* the runner hands out. The census reads both halves from the producer rather
64+
* than trusting this comment:
65+
*
66+
* - the helper's body calls no `throwSandboxFault` (it has no fault arm), and
67+
* no fault site's message reads as a capability denial;
68+
* - each gate's message (argument 3) is rendered as before, then run through
69+
* the helper's own sentence template (its `new Error(…)`) with the code and
70+
* status its `Object.assign` declares, once per origin kind.
7171
*/
7272

7373
import { readFileSync } from 'node:fs';
@@ -105,18 +105,15 @@ const SAMPLES: Readonly<Record<string, string>> = {
105105
fieldName: 'owner',
106106
};
107107

108-
/**
109-
* [#26903] A `throwCapabilityDenial` site is a fault only on the non-hook arm,
110-
* so its message is rendered with an action origin (see the header).
111-
*/
112-
const CAPABILITY_FAULT_SAMPLES: Readonly<Record<string, string>> = {
113-
...SAMPLES,
114-
'origin.kind': 'action',
115-
};
116-
117108
/** The helper every capability gate throws through, read below from the producer. */
118109
const CAPABILITY_HELPER = 'throwCapabilityDenial';
119110

111+
/** [#26965] Every origin a body runs under answers a denial the same way; §4 renders each. */
112+
const ORIGIN_KINDS = ['hook', 'action', 'job'] as const;
113+
114+
/** A placeholder for `origin.kind` in a gate's rendered message, filled per kind in §4. */
115+
const ORIGIN_KIND_HOLE = '@@origin.kind@@';
116+
120117
/** §2: the error a nested body threw, so the construction it lands in is a CRASH. */
121118
const CRASH_SAMPLES: Readonly<Record<string, string>> = {
122119
...SAMPLES,
@@ -130,7 +127,7 @@ const CRASH_SAMPLES: Readonly<Record<string, string>> = {
130127
* runner, or a new non-literal one, fails §1's census.
131128
*/
132129
const RELAYS: Readonly<Record<string, string>> = {
133-
message: '`throwSandboxFault` builds the error for each of its call sites, and `throwCapabilityDenial` hands it each of its own (action and job origins); both helpers\' call sites are sites here in their own right',
130+
message: '`throwSandboxFault` builds the error for each of its call sites, which are sites here in their own right',
134131
'withoutSandboxErrorPrefix(String(errStr))':
135132
'the pump loop rebuilds a fault marked across the VM hop: the `throwSandboxFault` messages, the host-side faults thrown inside a host call (sites here), and a nested body\'s crash (§2)',
136133
};
@@ -139,7 +136,6 @@ const RELAYS: Readonly<Record<string, string>> = {
139136
const KINDS: ReadonlyArray<readonly [kind: string, pattern: RegExp, floor: number]> = [
140137
['CPU budget', /exceeded CPU budget/, 1],
141138
['wall-clock ceiling', /exceeded wall-clock ceiling/, 1],
142-
['capability denial', /^capability '/, 5],
143139
['ctx.api unavailable', /^ctx\.api unavailable/, 1],
144140
['install failure', /^failed to install/, 4],
145141
['marshal failure', /^failed to marshal/, 1],
@@ -207,7 +203,7 @@ const census = (() => {
207203
const crashes: Site[] = [];
208204
const relays: string[] = [];
209205
const unrenderable: string[] = [];
210-
const capabilityGates: string[] = [];
206+
const capabilityGates: CapabilityGate[] = [];
211207
const visit = (node: ts.Node): void => {
212208
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'SandboxError') {
213209
const args = node.arguments ?? [];
@@ -246,15 +242,21 @@ const census = (() => {
246242
}
247243
}
248244
}
249-
// [#26903] A capability gate: a fault on the action/job arm (see the header).
245+
// [#26965] A capability gate: a refusal for every origin, held by §4.
246+
// Its message is rendered per origin kind there; `origin.kind` stays a
247+
// placeholder here.
250248
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === CAPABILITY_HELPER) {
251249
const at = `quickjs-runner.ts:${lineOf(node)}`;
252250
try {
253-
faults.push({
254-
label: `${at} ${CAPABILITY_HELPER}(…), action origin`,
255-
message: render(node.arguments[3], CAPABILITY_FAULT_SAMPLES),
251+
capabilityGates.push({
252+
label: `${at} ${CAPABILITY_HELPER}(…)`,
253+
message: render(node.arguments[3], { ...SAMPLES, 'origin.kind': ORIGIN_KIND_HOLE }),
254+
// The capability is a literal token, or the `required` parameter a
255+
// gate passes on (its sample is the token it is called with).
256+
capability: ts.isIdentifier(node.arguments[2]) && node.arguments[2].text in SAMPLES
257+
? SAMPLES[node.arguments[2].text]
258+
: render(node.arguments[2], SAMPLES),
256259
});
257-
capabilityGates.push(at);
258260
} catch (e) {
259261
unrenderable.push(`${at}: ${(e as Error).message}`);
260262
}
@@ -265,28 +267,85 @@ const census = (() => {
265267
return { faults, crashes, relays, unrenderable, capabilityGates };
266268
})();
267269

270+
/** The value a `const NAME = <literal>` declaration in the producer holds. */
271+
function constValue(name: string): string | number | undefined {
272+
let found: string | number | undefined;
273+
const visit = (node: ts.Node): void => {
274+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) {
275+
const init = node.initializer;
276+
if (ts.isStringLiteral(init)) found = init.text;
277+
else if (ts.isNumericLiteral(init)) found = Number(init.text);
278+
}
279+
ts.forEachChild(node, visit);
280+
};
281+
visit(SOURCE);
282+
return found;
283+
}
284+
268285
/**
269-
* [#26903] Does the capability helper send every non-hook origin to
270-
* `throwSandboxFault` before anything else? Read from its body: the first
271-
* statement must be `if (origin.kind !== 'hook') throwSandboxFault(vm, message)`
272-
* (the parameter names read from its own signature).
286+
* [#26965] The capability helper, read from its body: whether it can still
287+
* reach `throwSandboxFault`, the sentence template its `new Error(…)` builds,
288+
* and the `code` / `status` its `Object.assign` declares.
273289
*/
274-
function capabilityHelperFaultsNonHook(): boolean {
275-
let ok = false;
290+
const capabilityHelper = (() => {
291+
let callsFault = false;
292+
let template: ts.Expression | undefined;
293+
let code: unknown;
294+
let status: unknown;
295+
const valueOf = (e: ts.Expression): unknown => {
296+
if (ts.isStringLiteral(e)) return e.text;
297+
if (ts.isNumericLiteral(e)) return Number(e.text);
298+
if (ts.isIdentifier(e)) return constValue(e.text);
299+
return undefined;
300+
};
301+
const visitBody = (node: ts.Node): void => {
302+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'throwSandboxFault') callsFault = true;
303+
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'Error') template = node.arguments?.[0];
304+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)) {
305+
if (node.name.text === 'code') code = valueOf(node.initializer);
306+
if (node.name.text === 'status') status = valueOf(node.initializer);
307+
}
308+
ts.forEachChild(node, visitBody);
309+
};
310+
let params: string[] = [];
276311
const visit = (node: ts.Node): void => {
277312
if (ts.isFunctionDeclaration(node) && node.name?.text === CAPABILITY_HELPER && node.body) {
278-
const [vm, origin, , message] = node.parameters.map((p) => p.name.getText());
279-
const first = node.body.statements[0];
280-
ok = first !== undefined
281-
&& ts.isIfStatement(first)
282-
&& first.elseStatement === undefined
283-
&& first.expression.getText() === `${origin}.kind !== 'hook'`
284-
&& first.thenStatement.getText().replace(/;$/, '') === `throwSandboxFault(${vm}, ${message})`;
313+
params = node.parameters.map((p) => p.name.getText());
314+
visitBody(node.body);
285315
}
286316
ts.forEachChild(node, visit);
287317
};
288318
visit(SOURCE);
289-
return ok;
319+
const [, origin, capability, message] = params;
320+
/** The sentence the helper builds for one gate under one origin kind. */
321+
const sentence = (gate: CapabilityGate, kind: string): string => {
322+
if (template === undefined) throw new Error(`${CAPABILITY_HELPER} builds no \`new Error(…)\``);
323+
const gateText = gate.message.split(ORIGIN_KIND_HOLE).join(kind);
324+
return render(template, {
325+
[`${message}.replace(/\\.$/, '')`]: gateText.replace(/\.$/, ''),
326+
[message]: gateText,
327+
[capability]: gate.capability,
328+
[`${origin}.kind`]: kind,
329+
});
330+
};
331+
return { callsFault, code, status, sentence, found: params.length === 4 };
332+
})();
333+
334+
interface CapabilityGate extends Site {
335+
capability: string;
336+
}
337+
338+
/**
339+
* [#26965] The error the runner hands out for a denied capability: the pump
340+
* loop's relay of a coded refusal — the `<kind> '<name>' threw:` wrapper in
341+
* `.message` for the log, the sentence in `innerMessage`, and the code and
342+
* status the refusal carried across the VM.
343+
*/
344+
function capabilityRefusal(gate: CapabilityGate, kind: string): Error {
345+
const text = capabilityHelper.sentence(gate, kind);
346+
const err = new Error(`${kind} '${SAMPLES['origin.name']}' threw: ${PRODUCER_NAME}: ${text}`);
347+
err.name = PRODUCER_NAME ?? 'unread';
348+
return Object.assign(err, { innerMessage: text, code: capabilityHelper.code, status: capabilityHelper.status });
290349
}
291350

292351
const PRODUCER_NAME = producerName();
@@ -296,6 +355,11 @@ function namingTheObject(): Site[] {
296355
return census.faults.filter((s) => s.message.includes(`'${OBJECT}'`));
297356
}
298357

358+
/** [#26965] The capability gates whose rendered message quotes the requested object. */
359+
function gatesNamingTheObject(): CapabilityGate[] {
360+
return census.capabilityGates.filter((s) => s.message.includes(`'${OBJECT}'`));
361+
}
362+
299363
/** The producer's own fault: its declared `name`, and no `innerMessage`. */
300364
function ownFault(message: string): Error {
301365
const err = new Error(message);
@@ -349,21 +413,24 @@ describe('[#22741] §1 — every fault the runner raises itself reads the door\'
349413
expect(PRODUCER_NAME, 'the `this.name = …` assignment in SandboxError\'s constructor').toBeTypeOf('string');
350414
expect(census.unrenderable, 'sites this pin cannot render; add a sample or a relay').toEqual([]);
351415
expect([...new Set(census.relays)].sort()).toEqual(Object.keys(RELAYS).sort());
352-
// [#26903] The capability gates throw through the helper, and the helper
353-
// keeps their action/job arm on `throwSandboxFault`, the fault rows below.
416+
// [#26965] The capability gates throw through the helper, and the helper
417+
// has no fault arm: no gate is a fault row here (§4 holds them).
354418
expect(census.capabilityGates.length, `${CAPABILITY_HELPER} call sites`).toBeGreaterThanOrEqual(5);
355-
expect(capabilityHelperFaultsNonHook(), `${CAPABILITY_HELPER} sends every non-hook origin to throwSandboxFault first`).toBe(true);
419+
expect(capabilityHelper.found, `${CAPABILITY_HELPER} is declared with (vm, origin, capability, message)`).toBe(true);
420+
expect(capabilityHelper.callsFault, `${CAPABILITY_HELPER} reaches throwSandboxFault`).toBe(false);
421+
expect(census.faults.filter((s) => /^capability '/.test(s.message)), 'a capability denial raised as a fault').toEqual([]);
356422
for (const [kind, pattern, floor] of KINDS) {
357423
const n = census.faults.filter((s) => pattern.test(s.message)).length;
358424
expect(n, `${kind}: sites found`).toBeGreaterThanOrEqual(floor);
359425
}
360426
// §2's population: the constructions that set `innerMessage` (crash and refusal sites).
361427
expect(census.crashes.length).toBeGreaterThanOrEqual(4);
362-
// [#22868] The sites that name the requested object: the `ctx.api.object(…)`
363-
// capability gate and the missing-method arm, each held to `500` below.
428+
// [#22868] The fault site that names the requested object: the
429+
// missing-method arm, held to `500` below. [#26965] The `ctx.api.object(…)`
430+
// capability gate names it too, and §4 holds it to its `403`.
364431
const named = namingTheObject();
365-
expect(named.filter((s) => /^capability '/.test(s.message)).length, 'capability denial').toBeGreaterThanOrEqual(1);
366432
expect(named.filter((s) => /not implemented$/.test(s.message)).length, 'missing ctx.api method').toBeGreaterThanOrEqual(1);
433+
expect(gatesNamingTheObject().length, 'the ctx.api.object(…) capability gate').toBeGreaterThanOrEqual(1);
367434
});
368435

369436
it.each(census.faults.map((s) => [s.label, s.message] as const))('%s', async (_label, message) => {
@@ -426,7 +493,10 @@ describe('[#22741] §3 — controls: what the predicate must NOT change', () =>
426493

427494
it('CONTROL [#22868] — the same object-naming texts, thrown by anything but the sandbox, keep the heuristic\'s 404', () => {
428495
// The door asks the producer's identity, never the words: no second rule.
429-
const named = namingTheObject();
496+
const named: Site[] = [
497+
...namingTheObject(),
498+
...gatesNamingTheObject().map((g) => ({ label: g.label, message: g.message.split(ORIGIN_KIND_HOLE).join('hook') })),
499+
];
430500
expect(named.length).toBeGreaterThanOrEqual(2);
431501
for (const { label, message } of named) {
432502
const err = new Error(message);
@@ -461,3 +531,31 @@ describe('[#22741] §3 — controls: what the predicate must NOT change', () =>
461531
}
462532
});
463533
});
534+
535+
describe('[#26965] §4 — a capability denial is a coded refusal for every origin, on the door and on the row', () => {
536+
it('the helper declares the standard catalog code at its status', () => {
537+
expect(capabilityHelper.code).toBe('INSUFFICIENT_PRIVILEGES');
538+
expect(capabilityHelper.status).toBe(403);
539+
});
540+
541+
const cases = census.capabilityGates.flatMap((gate) => ORIGIN_KINDS.map((kind) => [`${gate.label}, ${kind} origin`, gate, kind] as const));
542+
543+
it.each(cases)('%s', async (_label, gate, kind) => {
544+
const sentence = capabilityHelper.sentence(gate, kind);
545+
// The remedy names the body's own owner, never another origin's.
546+
expect(sentence).toContain(`capability '${gate.capability}' not granted to ${kind} '`);
547+
expect(sentence).toContain(`Grant it by adding '${gate.capability}' to the ${kind}'s body.capabilities.`);
548+
549+
const err = capabilityRefusal(gate, kind);
550+
expect(isSandboxOwnFault(err)).toBe(false);
551+
// The door answers the refusal, never the fault's 500 nor, for the gate
552+
// that names the requested object, the unknown-object heuristic's 404.
553+
const door = mapDataError(err, OBJECT);
554+
expect(door.status).toBe(403);
555+
expect(door.body).toMatchObject({ error: sentence, code: 'INSUFFICIENT_PRIVILEGES' });
556+
557+
// The row stays a row error, reading the refusal's own sentence and code.
558+
const row = await rowFor(() => capabilityRefusal(gate, kind));
559+
expect(row).toEqual({ row: 1, ok: false, action: 'failed', error: sentence, code: 'INSUFFICIENT_PRIVILEGES' });
560+
});
561+
});

0 commit comments

Comments
 (0)