Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/26903-hook-capability-denial-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a hook refused for a missing capability answers a coded 403 naming the hook and the capability, not a bare 500 (#26903)

Clause-②: no
Metadata-protocol: none

**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.

**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)`.

**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.
78 changes: 75 additions & 3 deletions packages/core/src/utils/import-runner-sandbox-fault-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@
* until the door asked `isSandboxOwnFault` first. So §1 holds each site that
* names the requested object to the door's `500`, and §3 holds the controls
* that keep the heuristic's other answers where they were.
*
* [#26903] The capability gates call `throwCapabilityDenial(vm, origin,
* capability, message)`, not `throwSandboxFault` directly. Each of those calls
* is still a site here, its message rendered from argument 3, but with an
* ACTION origin: the helper sends an action or job body's denial to
* `throwSandboxFault` (the fault this file pins) and answers a HOOK body's as
* a coded `403` refusal, which is not a fault and is pinned by
* `runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts`.
* The census reads that routing from the helper's body rather than trusting
* this comment: if its first statement stops sending every non-hook origin to
* `throwSandboxFault`, the census is red. The helper's own
* `throwSandboxFault(vm, message)` relays its call sites, like the
* `new SandboxError(message)` inside `throwSandboxFault`.
*/

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

/**
* [#26903] A `throwCapabilityDenial` site is a fault only on the non-hook arm,
* so its message is rendered with an action origin (see the header).
*/
const CAPABILITY_FAULT_SAMPLES: Readonly<Record<string, string>> = {
...SAMPLES,
'origin.kind': 'action',
};

/** The helper every capability gate throws through, read below from the producer. */
const CAPABILITY_HELPER = 'throwCapabilityDenial';

/** §2: the error a nested body threw, so the construction it lands in is a CRASH. */
const CRASH_SAMPLES: Readonly<Record<string, string>> = {
...SAMPLES,
Expand All @@ -105,7 +130,7 @@ const CRASH_SAMPLES: Readonly<Record<string, string>> = {
* runner, or a new non-literal one, fails §1's census.
*/
const RELAYS: Readonly<Record<string, string>> = {
message: '`throwSandboxFault` builds the error for each of its call sites, which are sites here in their own right',
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',
'withoutSandboxErrorPrefix(String(errStr))':
'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)',
};
Expand Down Expand Up @@ -182,6 +207,7 @@ const census = (() => {
const crashes: Site[] = [];
const relays: string[] = [];
const unrenderable: string[] = [];
const capabilityGates: string[] = [];
const visit = (node: ts.Node): void => {
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'SandboxError') {
const args = node.arguments ?? [];
Expand All @@ -208,19 +234,61 @@ const census = (() => {
}
}
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'throwSandboxFault') {
const at = `quickjs-runner.ts:${lineOf(node)}`;
const text = node.arguments[1]?.getText() ?? '';
if (text in RELAYS) {
relays.push(text);
} else {
try {
faults.push({ label: `${at} throwSandboxFault(…)`, message: render(node.arguments[1], SAMPLES) });
} catch (e) {
unrenderable.push(`${at}: ${(e as Error).message}`);
}
}
}
// [#26903] A capability gate: a fault on the action/job arm (see the header).
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === CAPABILITY_HELPER) {
const at = `quickjs-runner.ts:${lineOf(node)}`;
try {
faults.push({ label: `${at} throwSandboxFault(…)`, message: render(node.arguments[1], SAMPLES) });
faults.push({
label: `${at} ${CAPABILITY_HELPER}(…), action origin`,
message: render(node.arguments[3], CAPABILITY_FAULT_SAMPLES),
});
capabilityGates.push(at);
} catch (e) {
unrenderable.push(`${at}: ${(e as Error).message}`);
}
}
ts.forEachChild(node, visit);
};
visit(SOURCE);
return { faults, crashes, relays, unrenderable };
return { faults, crashes, relays, unrenderable, capabilityGates };
})();

/**
* [#26903] Does the capability helper send every non-hook origin to
* `throwSandboxFault` before anything else? Read from its body: the first
* statement must be `if (origin.kind !== 'hook') throwSandboxFault(vm, message)`
* (the parameter names read from its own signature).
*/
function capabilityHelperFaultsNonHook(): boolean {
let ok = false;
const visit = (node: ts.Node): void => {
if (ts.isFunctionDeclaration(node) && node.name?.text === CAPABILITY_HELPER && node.body) {
const [vm, origin, , message] = node.parameters.map((p) => p.name.getText());
const first = node.body.statements[0];
ok = first !== undefined
&& ts.isIfStatement(first)
&& first.elseStatement === undefined
&& first.expression.getText() === `${origin}.kind !== 'hook'`
&& first.thenStatement.getText().replace(/;$/, '') === `throwSandboxFault(${vm}, ${message})`;
}
ts.forEachChild(node, visit);
};
visit(SOURCE);
return ok;
}

const PRODUCER_NAME = producerName();

/** [#22868] The fault sites whose rendered message quotes the requested object. */
Expand Down Expand Up @@ -281,6 +349,10 @@ describe('[#22741] §1 — every fault the runner raises itself reads the door\'
expect(PRODUCER_NAME, 'the `this.name = …` assignment in SandboxError\'s constructor').toBeTypeOf('string');
expect(census.unrenderable, 'sites this pin cannot render; add a sample or a relay').toEqual([]);
expect([...new Set(census.relays)].sort()).toEqual(Object.keys(RELAYS).sort());
// [#26903] The capability gates throw through the helper, and the helper
// keeps their action/job arm on `throwSandboxFault`, the fault rows below.
expect(census.capabilityGates.length, `${CAPABILITY_HELPER} call sites`).toBeGreaterThanOrEqual(5);
expect(capabilityHelperFaultsNonHook(), `${CAPABILITY_HELPER} sends every non-hook origin to throwSandboxFault first`).toBe(true);
for (const [kind, pattern, floor] of KINDS) {
const n = census.faults.filter((s) => pattern.test(s.message)).length;
expect(n, `${kind}: sites found`).toBeGreaterThanOrEqual(floor);
Expand Down
107 changes: 107 additions & 0 deletions packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#26903] A hook refused for a missing capability, end to end through the real
// stack: QuickJS capability gate → ObjectQL triggerHooks → the data door → the
// HTTP body a client reads.
//
// The gate stays as strict as it is. What this file pins is the ANSWER: a save
// on an object whose `beforeInsert` body calls `ctx.api` without `api.read`
// used to get `500 INTERNAL_ERROR` ("Internal server error"), telling neither
// the caller nor the author which hook or which capability. It is a refusal
// now, `403 INSUFFICIENT_PRIVILEGES` (a standard catalog code), naming both,
// and nothing is written. Under `onError: 'log'` the save proceeds. The same
// body WITH the capability runs.
//
// The composition pins (every gate, the log line) live in
// `packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts`;
// this file is the one place the whole HTTP chain runs.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { defineStack } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

const READ_SOURCE = "const rows = await ctx.api.object('hcr_note').find({}); ctx.input.title = ctx.input.title + ' (' + rows.length + ')';";

const note = (name: string, label: string) =>
ObjectSchema.create({ name, label, fields: { title: Field.text({ label: 'Title', required: true }) } });

const hcrStack = defineStack({
manifest: {
id: 'com.dogfood.hook-capability-refusal',
namespace: 'hcr',
version: '0.0.0',
type: 'app',
name: 'Hook Capability Refusal Fixture',
description: 'Sandboxed hooks calling ctx.api with and without the api.read capability.',
},
objects: [note('hcr_note', 'Note'), note('hcr_logged', 'Logged'), note('hcr_granted', 'Granted')],
hooks: [
{
name: 'hcr_note_guard',
object: 'hcr_note',
events: ['beforeInsert'],
body: { language: 'js', source: READ_SOURCE, capabilities: [] },
},
{
name: 'hcr_logged_guard',
object: 'hcr_logged',
events: ['beforeInsert'],
onError: 'log',
body: { language: 'js', source: READ_SOURCE, capabilities: [] },
},
{
name: 'hcr_granted_guard',
object: 'hcr_granted',
events: ['beforeInsert'],
body: { language: 'js', source: READ_SOURCE, capabilities: ['api.read'] },
},
],
});

describe('objectstack verify: a hook refused for a missing capability (#26903)', () => {
let stack: VerifyStack;
let token: string;

beforeAll(async () => {
stack = await bootStack(hcrStack);
token = await stack.signIn();
}, 60_000);

afterAll(async () => {
await stack?.stop();
});

const titles = async (object: string): Promise<string[]> => {
const r = await stack.apiAs(token, 'GET', `/data/${object}`);
expect(r.status).toBe(200);
const body = (await r.json()) as any;
return (body.records ?? body.data ?? []).map((row: any) => row.title);
};

it('onError: abort — POST answers 403 INSUFFICIENT_PRIVILEGES naming the hook and api.read', async () => {
const r = await stack.apiAs(token, 'POST', '/data/hcr_note', { title: 'refused' });
const body = (await r.json()) as any;
expect(r.status, JSON.stringify(body)).toBe(403);
expect(body.code).toBe('INSUFFICIENT_PRIVILEGES');
expect(body.error).toContain("capability 'api.read' not granted to hook 'hcr_note_guard'");
expect(body.error).toContain("adding 'api.read' to the hook's body.capabilities");
expect(JSON.stringify(body)).not.toMatch(/threw:|SandboxError|Internal server error/);
});

it('ground truth: the refused save wrote nothing', async () => {
expect(await titles('hcr_note')).toEqual([]);
});

it('onError: log — the save proceeds', async () => {
const r = await stack.apiAs(token, 'POST', '/data/hcr_logged', { title: 'logged' });
expect(r.status, await r.clone().text()).toBeLessThan(300);
expect(await titles('hcr_logged')).toEqual(['logged']);
});

it('control — the same body WITH api.read runs', async () => {
const r = await stack.apiAs(token, 'POST', '/data/hcr_granted', { title: 'granted' });
expect(r.status, await r.clone().text()).toBeLessThan(300);
expect(await titles('hcr_granted')).toEqual(['granted (0)']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
* `domains/actions-fault-vs-rejection.test.ts`, which already states that a
* `SandboxError` with no `innerMessage` is a 500 — this file is what makes a
* real capability denial actually arrive in that shape.
*
* [#26903] These are ACTION-origin denials. A HOOK body's denial is a coded
* refusal (`403 INSUFFICIENT_PRIVILEGES`) since that card, pinned by
* `hook-capability-denial-is-a-refusal.integration.test.ts`.
*/

import { describe, it, expect } from 'vitest';
Expand Down
Loading
Loading