Skip to content

Commit e46e25c

Browse files
fix(runtime): a hook refused for a missing capability answers a coded 403 naming the hook and the capability, not a bare 500 (#26963)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9c376YYAn4DZo8EvEWKB1w)_ Fixes #26903 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:hook 的沙箱 body 调用了没授予的能力(如没声明 `api.read` 却调 `ctx.api.find`)时,保存记录不再回 `500 INTERNAL_ERROR`,而是回 `403 INSUFFICIENT_PRIVILEGES`(标准目录已有的码,未新增任何码),消息点名是哪个 hook、缺哪个能力、怎么补。 - 为什么改:用户和作者原来只看到「Internal server error」,不知道是哪个 hook、缺什么;这是 Studio 里写 hook 最常见的坑(#26726 的服务端一半)。 - 风险与回滚:能力检查本身一点没放松,有能力的 hook 行为不变;只改 hook 来源的拒绝怎么作答,action/job body 缺能力仍是 500。回滚即 revert 本 PR。 - 先看:`packages/runtime/src/sandbox/quickjs-runner.ts` 的 `throwCapabilityDenial`;`hook-capability-denial-is-a-refusal.integration.test.ts`;`hook-capability-refusal.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts`,预期 15 项全过(5 个能力 × abort / log / 有能力对照)。 - 需要你做的:无,席位落地。 ## What changes - **`@objectstack/runtime`**, `quickjs-runner.ts`: every capability gate (`ctx.api.object(x).METHOD` for `api.read` / `api.write`, `ctx.api.transaction`, `ctx.log.*`, `ctx.crypto.randomUUID`, `ctx.title(field)`) now throws through one helper, `throwCapabilityDenial`. In a **hook** body the denial crosses the VM as a coded refusal: a plain error carrying `code: 'INSUFFICIENT_PRIVILEGES'` and `status: 403`, exactly the shape a body's own `throw Object.assign(new Error(…), { code, status })` has. The pump loop then builds the same `SandboxError` (with `innerMessage`, `code`, `status`) it builds for that throw, and `mapDataError`'s sandbox unwrap arm answers it. The sentence keeps the gate's existing text and adds the remedy: `capability 'api.read' not granted to hook 'H' (called ctx.api.object('OBJ').find). Grant it by adding 'api.read' to the hook's body.capabilities.` - In an **action** or **job** body the helper calls `throwSandboxFault` as before, so those keep the #4431 fault contract (`500`). The seat ruling covered hook bodies only. - `scripts/error-status-unpinned-baseline.json`: `INSUFFICIENT_PRIVILEGES` leaves the unpinned list, because a producer now declares its status (`check:error-status-conformance --update`, a ratchet-down). - Docblocks on `SandboxError.innerMessage`, the pump loop's fault branch and `capability-denial-is-a-fault.test.ts` say a hook denial is the exception. - Changeset: `@objectstack/runtime` patch. ## Why A hook whose body calls `ctx.api` without the capability is a common authoring mistake (QA run G11). The gate refused it correctly, but the runner marked the denial as its own fault, so a save answered `500 INTERNAL_ERROR` with `Internal server error`. Neither the caller nor the author learned which hook or which capability. Per the seat ruling, a capability refusal in a hook body is a refusal, and it is answered with a code the spec already declares. ## Tests - `packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts` (new, 15): real `ObjectQL`, real `SqlDriver` (better-sqlite3), real `QuickJSScriptRunner` and hook wrapper, read off `mapDataError`. For each of the five tokens: - `onError: abort`: `403`, `code: INSUFFICIENT_PRIVILEGES`, the sentence names the hook, the capability and the remedy, carries neither `threw:` nor `SandboxError`, no row is written, and the BodyRunner logs one `info` refusal line with no `error` line. - `onError: log`: the save proceeds and the `[hook] handler failed (onError=log; suppressing)` line names the hook, object, event and the capability. - Control: the same body with the capability runs, and nothing is logged as refused or failed. - `packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts` (new, 4): the real stack through `POST /data/OBJECT` (abort → `403 INSUFFICIENT_PRIVILEGES` naming hook and `api.read`, nothing written; `log` → saved; control → saved and the body's read ran). ## Acceptance notes - **The `onError: log` half of the card did not reproduce on current main.** At `e1ab32ed` the denial is already logged under `log`, twice at `ERROR` (live `os dev` log below; the composition `log` cases pass on main too). What the QA run likely read as "nothing is logged" is the body's own `ctx.log.info(…)` line, which the gate correctly refuses without `log`. This PR keeps the hook-error line (now with the remedy) and pins it. Under `log` the BodyRunner line moves from `ERROR` with a stack to the `info` refusal line, like every other hook refusal. - **Code choice.** `INSUFFICIENT_PRIVILEGES` ("Operation requires higher privileges") is a `StandardErrorCode` member. It is not `PERMISSION_DENIED`, which reads as the *caller* lacking a permission and whose `[Security] Access denied` prefix matcher would flatten it. No ledger row and no spec change. #26831 is still open and unmerged, so no code from it was used. - **Console.** The console's `isPermissionError` treats every 403 as its fixed "no permission" line, so the Studio/record toast does not show this sentence until a UI card reads `code`. That belongs with #26726 (the UI half). No UI change here. - **Action and job bodies** still answer `500` for a capability denial (`capability-denial-is-a-fault.test.ts`, unchanged). Aligning them is a separate decision, raised in the report. - **Overlap with #26831** (open, touches `quickjs-runner.ts` and `body-runner.ts`): `git merge-tree` with its head `e5597836` auto-merges `quickjs-runner.ts`. Semantically its host-relay marker is set in `hostErrorToVm`, which this denial goes through, so a hook with a `refusals` catalogue passes the 403 through unchanged. - `packages/types` keeps its comment that cites a hook capability text as an example of a sandbox own fault (`data-error-classification.ts`). It is still true for action and job bodies and is not touched here (outside the claim's file surface). ## Verification **At head `60488efa`**, branched from `origin/main` `e1ab32ed`. Every build and test ran under `os-verify-lock`. - **Dispatch gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 71 families on this diff. All 71 were run with each exit code captured before any pipe, and all 71 exited 0. `--ran` reconciliation: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - Round 1 (at `2f1dacec`) had two reds, both fixed: `check:driver-memory-census` (the composition test first used `InMemoryDriver`; it now uses `SqlDriver` on better-sqlite3, no ledger row added) and `check:error-status-conformance` (`INSUFFICIENT_PRIVILEGES` is now status-pinned by a producer; the baseline was ratcheted down with `--update`). - Full `pnpm build` before the gates: exit 0 (116 of 116 tasks). - **Live red/green through `POST /api/v1/data`** (`pnpm dev -- --fresh`, showcase, a writable package `app.probe1`, object `probe1_note`, hook `probe1_guard` on `beforeInsert`, body reads `ctx.api.object('probe1_note').find({})`): <details><summary>main (runtime rebuilt from the base blob)</summary> ``` abort, no capability: {"error":"Internal server error","code":"INTERNAL_ERROR"} 500 records: ['before hooks'] log, no capability: ERROR [BodyRunner] sandboxed hook threw {"appId":"runtime-authored","hook":"probe1_guard","error":{"message":"capability 'log' not granted to hook 'probe1_guard'", "stack": "SandboxError: …"}} ERROR [hook] handler failed (onError=log; suppressing) {"hook":"probe1_guard","object":"probe1_note","event":"beforeInsert","error":"capability 'log' not granted to hook 'probe1_guard'"} ``` </details> <details><summary>this branch</summary> ``` abort, no capability: {"error":"capability 'api.read' not granted to hook 'probe1_guard' (called ctx.api.object('probe1_note').find). Grant it by adding 'api.read' to the hook's body.capabilities.","code":"INSUFFICIENT_PRIVILEGES","object":"probe1_note"} 403 records: ['before hooks'] control, with api.read: 201, records: ['before hooks', 'control probe (1)'] log, no capability: 201 ERROR [hook] handler failed (onError=log; suppressing) {"hook":"probe1_guard","object":"probe1_note","event":"beforeInsert","error":"hook 'probe1_guard' threw: SandboxError: capability 'log' not granted to hook 'probe1_guard'. Grant it by adding 'log' to the hook's body.capabilities."} records: ['before hooks', 'control probe (1)', 'log probe'] ``` </details> - **Red first, each pin.** With `quickjs-runner.ts` set to its `e1ab32ed` blob (committed fix first, restored with `git checkout HEAD --` and checked by blob hash and a clean `git status`): - composition file: 5 failed, 10 passed of 15. The five failures are exactly the five `onError: abort` cases (`expected 500 to be 403`). The `log` and control cases pass on main, which is the measured reading above. - dogfood file (runtime rebuilt, `ablation-dist-preflight --absent` confirmed the marker gone from `dist/`): 1 failed, 3 passed of 4, `{"error":"Internal server error","code":"INTERNAL_ERROR"}: expected 500 to be 403`. Restored, rebuilt, preflight confirmed the marker back in `dist/`, 4 of 4 pass. - `pnpm --filter @objectstack/runtime exec vitest run src/sandbox src/domains`: 188 files, 4590 tests passed. - `pnpm --filter @objectstack/runtime typecheck`: exit 0 (tsc + `check:test-typecheck`). - `eslint --no-inline-config` on `packages/runtime` and the new dogfood file: exit 0. - Dogfood: not owed as a full run (no `!`, no narrowing arm); the one new dogfood file was run as above. ## 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 by [Claude Code](https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7eb613c commit e46e25c

7 files changed

Lines changed: 436 additions & 9 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a hook refused for a missing capability answers a coded 403 naming the hook and the capability, not a bare 500 (#26903)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
**What was wrong.** A hook whose sandboxed body calls something its `body.capabilities` does not grant (for example `ctx.api.object('note').find(...)` without `api.read`) is refused by the capability check. The QuickJS runner marked that denial as its own fault, so a save on the hook's object under `onError: 'abort'` answered `500 INTERNAL_ERROR` with `Internal server error`. Neither the caller nor the author was told which hook, or which capability, or what to change.
11+
12+
**What changes.** In a hook body, a capability denial is now a refusal. `POST /api/v1/data/OBJECT` (and every other door that answers through `mapDataError`) answers `403 INSUFFICIENT_PRIVILEGES`, an existing standard catalog code, with a sentence such as `capability 'api.read' not granted to hook 'note_guard' (called ctx.api.object('note').find). Grant it by adding 'api.read' to the hook's body.capabilities.` Nothing is written. The BodyRunner logs it once at `info` as a refusal, not at `error` with a stack. This applies to every gate: `api.read`, `api.write`, `api.transaction`, `crypto.uuid` and `log`, and `ctx.title(field)`.
13+
14+
**Unchanged.** The capability check is exactly as strict as before, and a hook that has the capability runs as before. Under `onError: 'log'` the denial is still recorded on the `[hook] handler failed (onError=log; suppressing)` line, now with the remedy, and the save proceeds. An action or job body's capability denial still answers `500 INTERNAL_ERROR`. No code is added.

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

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@
5555
* until the door asked `isSandboxOwnFault` first. So §1 holds each site that
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.
58+
*
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`.
5871
*/
5972

6073
import { readFileSync } from 'node:fs';
@@ -92,6 +105,18 @@ const SAMPLES: Readonly<Record<string, string>> = {
92105
fieldName: 'owner',
93106
};
94107

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+
117+
/** The helper every capability gate throws through, read below from the producer. */
118+
const CAPABILITY_HELPER = 'throwCapabilityDenial';
119+
95120
/** §2: the error a nested body threw, so the construction it lands in is a CRASH. */
96121
const CRASH_SAMPLES: Readonly<Record<string, string>> = {
97122
...SAMPLES,
@@ -105,7 +130,7 @@ const CRASH_SAMPLES: Readonly<Record<string, string>> = {
105130
* runner, or a new non-literal one, fails §1's census.
106131
*/
107132
const RELAYS: Readonly<Record<string, string>> = {
108-
message: '`throwSandboxFault` builds the error for each of its call sites, which are sites here in their own right',
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',
109134
'withoutSandboxErrorPrefix(String(errStr))':
110135
'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)',
111136
};
@@ -182,6 +207,7 @@ const census = (() => {
182207
const crashes: Site[] = [];
183208
const relays: string[] = [];
184209
const unrenderable: string[] = [];
210+
const capabilityGates: string[] = [];
185211
const visit = (node: ts.Node): void => {
186212
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'SandboxError') {
187213
const args = node.arguments ?? [];
@@ -208,19 +234,61 @@ const census = (() => {
208234
}
209235
}
210236
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'throwSandboxFault') {
237+
const at = `quickjs-runner.ts:${lineOf(node)}`;
238+
const text = node.arguments[1]?.getText() ?? '';
239+
if (text in RELAYS) {
240+
relays.push(text);
241+
} else {
242+
try {
243+
faults.push({ label: `${at} throwSandboxFault(…)`, message: render(node.arguments[1], SAMPLES) });
244+
} catch (e) {
245+
unrenderable.push(`${at}: ${(e as Error).message}`);
246+
}
247+
}
248+
}
249+
// [#26903] A capability gate: a fault on the action/job arm (see the header).
250+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === CAPABILITY_HELPER) {
211251
const at = `quickjs-runner.ts:${lineOf(node)}`;
212252
try {
213-
faults.push({ label: `${at} throwSandboxFault(…)`, message: render(node.arguments[1], SAMPLES) });
253+
faults.push({
254+
label: `${at} ${CAPABILITY_HELPER}(…), action origin`,
255+
message: render(node.arguments[3], CAPABILITY_FAULT_SAMPLES),
256+
});
257+
capabilityGates.push(at);
214258
} catch (e) {
215259
unrenderable.push(`${at}: ${(e as Error).message}`);
216260
}
217261
}
218262
ts.forEachChild(node, visit);
219263
};
220264
visit(SOURCE);
221-
return { faults, crashes, relays, unrenderable };
265+
return { faults, crashes, relays, unrenderable, capabilityGates };
222266
})();
223267

268+
/**
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).
273+
*/
274+
function capabilityHelperFaultsNonHook(): boolean {
275+
let ok = false;
276+
const visit = (node: ts.Node): void => {
277+
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})`;
285+
}
286+
ts.forEachChild(node, visit);
287+
};
288+
visit(SOURCE);
289+
return ok;
290+
}
291+
224292
const PRODUCER_NAME = producerName();
225293

226294
/** [#22868] The fault sites whose rendered message quotes the requested object. */
@@ -281,6 +349,10 @@ describe('[#22741] §1 — every fault the runner raises itself reads the door\'
281349
expect(PRODUCER_NAME, 'the `this.name = …` assignment in SandboxError\'s constructor').toBeTypeOf('string');
282350
expect(census.unrenderable, 'sites this pin cannot render; add a sample or a relay').toEqual([]);
283351
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.
354+
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);
284356
for (const [kind, pattern, floor] of KINDS) {
285357
const n = census.faults.filter((s) => pattern.test(s.message)).length;
286358
expect(n, `${kind}: sites found`).toBeGreaterThanOrEqual(floor);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26903] A hook refused for a missing capability, end to end through the real
4+
// stack: QuickJS capability gate → ObjectQL triggerHooks → the data door → the
5+
// HTTP body a client reads.
6+
//
7+
// The gate stays as strict as it is. What this file pins is the ANSWER: a save
8+
// on an object whose `beforeInsert` body calls `ctx.api` without `api.read`
9+
// used to get `500 INTERNAL_ERROR` ("Internal server error"), telling neither
10+
// the caller nor the author which hook or which capability. It is a refusal
11+
// now, `403 INSUFFICIENT_PRIVILEGES` (a standard catalog code), naming both,
12+
// and nothing is written. Under `onError: 'log'` the save proceeds. The same
13+
// body WITH the capability runs.
14+
//
15+
// The composition pins (every gate, the log line) live in
16+
// `packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts`;
17+
// this file is the one place the whole HTTP chain runs.
18+
19+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
20+
import { bootStack, type VerifyStack } from '@objectstack/verify';
21+
import { defineStack } from '@objectstack/spec';
22+
import { ObjectSchema, Field } from '@objectstack/spec/data';
23+
24+
const READ_SOURCE = "const rows = await ctx.api.object('hcr_note').find({}); ctx.input.title = ctx.input.title + ' (' + rows.length + ')';";
25+
26+
const note = (name: string, label: string) =>
27+
ObjectSchema.create({ name, label, fields: { title: Field.text({ label: 'Title', required: true }) } });
28+
29+
const hcrStack = defineStack({
30+
manifest: {
31+
id: 'com.dogfood.hook-capability-refusal',
32+
namespace: 'hcr',
33+
version: '0.0.0',
34+
type: 'app',
35+
name: 'Hook Capability Refusal Fixture',
36+
description: 'Sandboxed hooks calling ctx.api with and without the api.read capability.',
37+
},
38+
objects: [note('hcr_note', 'Note'), note('hcr_logged', 'Logged'), note('hcr_granted', 'Granted')],
39+
hooks: [
40+
{
41+
name: 'hcr_note_guard',
42+
object: 'hcr_note',
43+
events: ['beforeInsert'],
44+
body: { language: 'js', source: READ_SOURCE, capabilities: [] },
45+
},
46+
{
47+
name: 'hcr_logged_guard',
48+
object: 'hcr_logged',
49+
events: ['beforeInsert'],
50+
onError: 'log',
51+
body: { language: 'js', source: READ_SOURCE, capabilities: [] },
52+
},
53+
{
54+
name: 'hcr_granted_guard',
55+
object: 'hcr_granted',
56+
events: ['beforeInsert'],
57+
body: { language: 'js', source: READ_SOURCE, capabilities: ['api.read'] },
58+
},
59+
],
60+
});
61+
62+
describe('objectstack verify: a hook refused for a missing capability (#26903)', () => {
63+
let stack: VerifyStack;
64+
let token: string;
65+
66+
beforeAll(async () => {
67+
stack = await bootStack(hcrStack);
68+
token = await stack.signIn();
69+
}, 60_000);
70+
71+
afterAll(async () => {
72+
await stack?.stop();
73+
});
74+
75+
const titles = async (object: string): Promise<string[]> => {
76+
const r = await stack.apiAs(token, 'GET', `/data/${object}`);
77+
expect(r.status).toBe(200);
78+
const body = (await r.json()) as any;
79+
return (body.records ?? body.data ?? []).map((row: any) => row.title);
80+
};
81+
82+
it('onError: abort — POST answers 403 INSUFFICIENT_PRIVILEGES naming the hook and api.read', async () => {
83+
const r = await stack.apiAs(token, 'POST', '/data/hcr_note', { title: 'refused' });
84+
const body = (await r.json()) as any;
85+
expect(r.status, JSON.stringify(body)).toBe(403);
86+
expect(body.code).toBe('INSUFFICIENT_PRIVILEGES');
87+
expect(body.error).toContain("capability 'api.read' not granted to hook 'hcr_note_guard'");
88+
expect(body.error).toContain("adding 'api.read' to the hook's body.capabilities");
89+
expect(JSON.stringify(body)).not.toMatch(/threw:|SandboxError|Internal server error/);
90+
});
91+
92+
it('ground truth: the refused save wrote nothing', async () => {
93+
expect(await titles('hcr_note')).toEqual([]);
94+
});
95+
96+
it('onError: log — the save proceeds', async () => {
97+
const r = await stack.apiAs(token, 'POST', '/data/hcr_logged', { title: 'logged' });
98+
expect(r.status, await r.clone().text()).toBeLessThan(300);
99+
expect(await titles('hcr_logged')).toEqual(['logged']);
100+
});
101+
102+
it('control — the same body WITH api.read runs', async () => {
103+
const r = await stack.apiAs(token, 'POST', '/data/hcr_granted', { title: 'granted' });
104+
expect(r.status, await r.clone().text()).toBeLessThan(300);
105+
expect(await titles('hcr_granted')).toEqual(['granted (0)']);
106+
});
107+
});

‎packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
* `domains/actions-fault-vs-rejection.test.ts`, which already states that a
2727
* `SandboxError` with no `innerMessage` is a 500 — this file is what makes a
2828
* real capability denial actually arrive in that shape.
29+
*
30+
* [#26903] These are ACTION-origin denials. A HOOK body's denial is a coded
31+
* refusal (`403 INSUFFICIENT_PRIVILEGES`) since that card, pinned by
32+
* `hook-capability-denial-is-a-refusal.integration.test.ts`.
2933
*/
3034

3135
import { describe, it, expect } from 'vitest';

0 commit comments

Comments
 (0)