From 818ff4b13a75204046e594a0ab8529204be165b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:16:37 +0000 Subject: [PATCH 1/5] fix(runtime): a hook body's capability denial answers a coded 403 refusal, not a 500 fault Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu --- ...ty-denial-is-a-refusal.integration.test.ts | 158 ++++++++++++++++++ .../runtime/src/sandbox/quickjs-runner.ts | 67 +++++++- 2 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts diff --git a/packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts b/packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts new file mode 100644 index 00000000000..c3fca03e714 --- /dev/null +++ b/packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26903] A HOOK body's capability denial is a coded REFUSAL, not a fault. + * + * ## What was measured broken + * + * A `beforeInsert` hook whose sandboxed body calls `ctx.api.find(…)` without + * `capabilities: ['api.read']` is refused by the gate ("capability 'api.read' + * not granted"). The runner marked that denial as the sandbox's OWN fault + * (#4431), so `mapDataError` answered the user's save with the sanitised + * `500 INTERNAL_ERROR`: no code, no hook, no capability, nothing the caller or + * the author could act on. + * + * ## The line this file pins + * + * - The gate is exactly as strict as before: the denied call never runs. + * - `onError: 'abort'`: the save is refused with `403 INSUFFICIENT_PRIVILEGES` + * (the standard catalog's member, no new code), whose sentence names the + * hook and the capability, and nothing is written. One gate per token. + * - `onError: 'log'`: the refusal is recorded where every hook error under + * `log` is recorded (the hook wrapper's `[hook] handler failed` line), naming + * the hook and the capability, and the save proceeds as `log` promises. + * - Control: the same body WITH the capability runs as it always has. + * - An ACTION body's denial keeps the #4431 fault contract + * (`capability-denial-is-a-fault.test.ts`); this card ruled on hooks only. + * + * Measured in composition — a real `ObjectQL`, a real driver, the real + * `QuickJSScriptRunner` and hook wrapper — and read off `mapDataError`, the + * ADR-0112 table every data door answers with. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mapDataError } from '@objectstack/types'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { hookBodyRunnerFactory } from './body-runner.js'; +import { QuickJSScriptRunner } from './quickjs-runner.js'; + +const NOTE = { + name: 'cap_note', + fields: { + title: { type: 'text', name: 'title' }, + stamp: { type: 'text', name: 'stamp' }, + }, +}; + +/** The object a nested write lands on, so the guard never re-fires on its own write. */ +const LOG = { name: 'cap_log', fields: { title: { type: 'text', name: 'title' } } }; + +interface Line { level: 'debug' | 'info' | 'warn' | 'error'; msg: string; err?: unknown; meta?: any } + +/** A `Logger`-contract double (`error` is `(message, error, meta)`) that records every call. */ +function recordingLogger() { + const lines: Line[] = []; + return { + lines, + debug: (msg: string, meta?: any) => { lines.push({ level: 'debug', msg, meta }); }, + info: (msg: string, meta?: any) => { lines.push({ level: 'info', msg, meta }); }, + warn: (msg: string, meta?: any) => { lines.push({ level: 'warn', msg, meta }); }, + error: (msg: string, err?: unknown, meta?: any) => { lines.push({ level: 'error', msg, err, meta }); }, + }; +} + +/** One gate per `HookBodyCapability` token: the call that trips it, as a hook body. */ +const GATES = [ + { capability: 'api.read', source: "await ctx.api.object('cap_note').find({});" }, + { capability: 'api.write', source: "await ctx.api.object('cap_log').insert({ title: 'nested' });" }, + { capability: 'api.transaction', source: 'await ctx.api.transaction(async () => {});' }, + { capability: 'crypto.uuid', source: 'ctx.input.stamp = ctx.crypto.randomUUID();' }, + { capability: 'log', source: "ctx.log.info('stamping');" }, +] as const; + +describe('[#26903] a hook body\'s capability denial is a coded refusal', () => { + let engine: ObjectQL | null = null; + + afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = null; + }); + + async function boot(hook: Record) { + const hookLog = recordingLogger(); + const bodyLog = recordingLogger(); + engine = new ObjectQL(); + engine.registerDriver(new InMemoryDriver() as any, true); + await engine.init(); + for (const o of [NOTE, LOG]) engine.registry.registerObject(o as any); + const bodyRunner = hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'cap', logger: bodyLog }); + bindHooksToEngine(engine, [hook as any], { packageId: 'cap', bodyRunner, logger: hookLog as any }); + return { e: engine, hookLog, bodyLog }; + } + + function guard(source: string, capabilities: string[], onError?: 'abort' | 'log') { + return { + name: 'cap_note_guard', + object: 'cap_note', + events: ['beforeInsert'], + ...(onError ? { onError } : {}), + body: { language: 'js', source, capabilities }, + }; + } + + async function rowCount(e: ObjectQL): Promise { + return (await e.find('cap_note', {})).length; + } + + for (const gate of GATES) { + it(`onError: abort — '${gate.capability}' denied answers 403 INSUFFICIENT_PRIVILEGES naming hook and capability, nothing written`, async () => { + const { e, bodyLog } = await boot(guard(gate.source, [])); + + const err = await e.insert('cap_note', { title: 'first' }).then(() => null, (x) => x); + expect(err, 'the denied call must refuse the save').not.toBeNull(); + + const { status, body } = mapDataError(err, 'cap_note'); + expect(status).toBe(403); + expect(body.code).toBe('INSUFFICIENT_PRIVILEGES'); + expect(String(body.error)).toContain(`capability '${gate.capability}' not granted to hook 'cap_note_guard'`); + expect(String(body.error)).toContain(`adding '${gate.capability}' to the hook's body.capabilities`); + // The caller's sentence carries neither the sandbox debug wrapper nor its name prefix. + expect(String(body.error)).not.toMatch(/threw:|SandboxError/); + + expect(await rowCount(e)).toBe(0); + + // Logged once on the refusal side of the BodyRunner's band: info, coded, no stack. + const refused = bodyLog.lines.filter((l) => l.msg.includes('sandboxed hook refused')); + expect(refused).toHaveLength(1); + expect(refused[0].level).toBe('info'); + expect(refused[0].meta).toMatchObject({ hook: 'cap_note_guard', code: 'INSUFFICIENT_PRIVILEGES', status: 403 }); + expect(bodyLog.lines.filter((l) => l.level === 'error')).toEqual([]); + }, 30_000); + } + + for (const gate of GATES) { + it(`onError: log — '${gate.capability}' denied is recorded in the hook-error log and the save proceeds`, async () => { + const { e, hookLog } = await boot(guard(gate.source, [], 'log')); + + await expect(e.insert('cap_note', { title: 'first' })).resolves.toBeTruthy(); + expect(await rowCount(e)).toBe(1); + + const recorded = hookLog.lines.filter((l) => l.msg.includes('handler failed (onError=log')); + expect(recorded).toHaveLength(1); + expect(recorded[0].meta).toMatchObject({ hook: 'cap_note_guard', object: 'cap_note', event: 'beforeInsert' }); + expect(String(recorded[0].meta?.error)).toContain(`capability '${gate.capability}' not granted to hook 'cap_note_guard'`); + }, 30_000); + } + + for (const gate of GATES) { + it(`control — the same body WITH '${gate.capability}' runs as before`, async () => { + const caps = [gate.capability]; + const { e, hookLog, bodyLog } = await boot(guard(gate.source, caps)); + + await expect(e.insert('cap_note', { title: 'first' })).resolves.toBeTruthy(); + expect(await rowCount(e)).toBe(1); + expect([...hookLog.lines, ...bodyLog.lines].filter((l) => /refused|failed|threw/.test(l.msg))).toEqual([]); + }, 30_000); + } +}); diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 1758a636cff..e533a355861 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -713,8 +713,10 @@ export class QuickJSScriptRunner implements ScriptRunner { const installTxLeaf = (name: string, run: () => Promise): void => { const fn = vm.newFunction(name, () => { if (!caps.has('api.transaction')) { - throwSandboxFault( + throwCapabilityDenial( vm, + origin, + 'api.transaction', `capability 'api.transaction' not granted to ${origin.kind} '${origin.name}' (called ctx.api.transaction)`, ); } @@ -809,7 +811,7 @@ export class QuickJSScriptRunner implements ScriptRunner { for (const level of ['debug', 'info', 'warn', 'error'] as const) { const fn = vm.newFunction(level, (msgH, dataH) => { if (!caps.has('log')) { - throwSandboxFault(vm, `capability 'log' not granted to ${origin.kind} '${origin.name}'`); + throwCapabilityDenial(vm, origin, 'log', `capability 'log' not granted to ${origin.kind} '${origin.name}'`); } const msg = vm.getString(msgH); // [#7448] `vm.dump`, NOT `vm.getString` — the marshalling every other @@ -835,7 +837,12 @@ export class QuickJSScriptRunner implements ScriptRunner { const cryptoObj = vm.newObject(); const uuidFn = vm.newFunction('randomUUID', () => { if (!caps.has('crypto.uuid')) { - throwSandboxFault(vm, `capability 'crypto.uuid' not granted to ${origin.kind} '${origin.name}'`); + throwCapabilityDenial( + vm, + origin, + 'crypto.uuid', + `capability 'crypto.uuid' not granted to ${origin.kind} '${origin.name}'`, + ); } const v = ctx.crypto?.randomUUID?.() ?? cryptoRandomUUID(); return vm.newString(v); @@ -885,8 +892,10 @@ export class QuickJSScriptRunner implements ScriptRunner { // "[object Object]". const fieldName = field === undefined || field === null ? undefined : String(field); if (fieldName !== undefined && !caps.has('api.read')) { - throwSandboxFault( + throwCapabilityDenial( vm, + origin, + 'api.read', `capability 'api.read' not granted to ${origin.kind} '${origin.name}' ` + `(called ctx.title('${fieldName}'), which reads the related record). ` + `ctx.title() with no argument needs no capability.`, @@ -1284,8 +1293,10 @@ function installApiMethod( // Capability gate — throw synchronously so the VM sees a normal exception at // the call site (mirrors ctx.log / ctx.crypto gating). if (!caps.has(required)) { - throwSandboxFault( + throwCapabilityDenial( vm, + origin, + required, `capability '${required}' not granted to ${origin.kind} '${origin.name}' (called ctx.api.object('${objectName}').${method})`, ); } @@ -1786,6 +1797,52 @@ function throwSandboxFault(vm: QuickJSContext, message: string): never { throw hostErrorToVm(vm, new SandboxError(message)); } +/** + * [#26903] The code and status a HOOK body's capability denial is answered + * with: the standard catalog's `INSUFFICIENT_PRIVILEGES` ("operation requires + * higher privileges", `@objectstack/spec` `errors.zod.ts`), at its 403. + */ +const HOOK_CAPABILITY_REFUSAL_CODE = 'INSUFFICIENT_PRIVILEGES'; +const HOOK_CAPABILITY_REFUSAL_STATUS = 403; + +/** + * [#26903] Throw a capability gate's denial OUT OF a host function. + * + * In a HOOK body the denial is a REFUSAL, not the sandbox faulting: the hook's + * metadata did not grant what its body called, and the caller whose save fired + * the hook is owed a coded answer naming the hook and the capability, not the + * sanitised `500 INTERNAL_ERROR` {@link throwSandboxFault} reaches. So the VM + * error carries `code` and `status` the way a body's own + * `throw Object.assign(new Error(…), { code, status })` does, and the pump loop + * builds the same `SandboxError` (with `innerMessage`) it builds for that + * throw. Every door then answers it through `mapDataError`'s sandbox unwrap + * arm — `403 INSUFFICIENT_PRIVILEGES` with this sentence — and the BodyRunner + * logs it on the refusal side of its band, and `onError: 'log'` records it + * where it records any hook error. + * + * ⛔ The gate is exactly as strict as before: only how its denial is answered + * changed. ⛔ A plain `Error`, never a `SandboxError` instance: an instance is + * what {@link hostErrorToVm} marks as a fault, and a host error with an + * `innerMessage` is what it relays as a NESTED body's refusal (which the outer + * catch then does not log). Neither is true of this denial. + * + * An action or job body's denial keeps the #4431 fault contract + * ({@link SANDBOX_FAULT_PROP}); this card ruled on hooks only. + */ +function throwCapabilityDenial( + vm: QuickJSContext, + origin: ScriptOrigin, + capability: HookBodyCapability, + message: string, +): never { + if (origin.kind !== 'hook') throwSandboxFault(vm, message); + const refusal = Object.assign( + new Error(`${message.replace(/\.$/, '')}. Grant it by adding '${capability}' to the hook's body.capabilities.`), + { name: 'SandboxError', code: HOOK_CAPABILITY_REFUSAL_CODE, status: HOOK_CAPABILITY_REFUSAL_STATUS }, + ); + throw hostErrorToVm(vm, refusal); +} + /** * Strip the `SandboxError: ` name prefix the VM's flattening prepends. * From 272753b5ba7377fd64fb200ff3adb7a3d6b968ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:26:05 +0000 Subject: [PATCH 2/5] docs(runtime): note the hook capability refusal beside the fault contract, add changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu --- .changeset/26903-hook-capability-denial-refusal.md | 14 ++++++++++++++ .../sandbox/capability-denial-is-a-fault.test.ts | 4 ++++ packages/runtime/src/sandbox/quickjs-runner.ts | 8 ++++++++ 3 files changed, 26 insertions(+) create mode 100644 .changeset/26903-hook-capability-denial-refusal.md diff --git a/.changeset/26903-hook-capability-denial-refusal.md b/.changeset/26903-hook-capability-denial-refusal.md new file mode 100644 index 00000000000..42e62aa586c --- /dev/null +++ b/.changeset/26903-hook-capability-denial-refusal.md @@ -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. diff --git a/packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts b/packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts index 76356dd61a0..e5d9144d0f5 100644 --- a/packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts +++ b/packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts @@ -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'; diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index e533a355861..6534d3728e5 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -486,6 +486,9 @@ export class QuickJSScriptRunner implements ScriptRunner { // as a CRASH and answers 500 through `errorFromThrown`, instead of // the 400 a denial used to get — and the client sees the capability // text without the `SandboxError: ` debug prefix. + // [#26903] A HOOK body's capability denial does not arrive here: it + // crosses as a coded refusal (`throwCapabilityDenial`) and takes the + // relay below like a body's own coded throw. if (info?.sandboxFault) { throw new SandboxError(withoutSandboxErrorPrefix(String(errStr))); } @@ -2017,6 +2020,11 @@ export class SandboxError extends Error { * the wrapped `.message` stays for server logs. Undefined for the sandbox's * own internal errors (capability denials, timeouts, marshalling failures), * which have no user-meaningful inner message. + * + * [#26903] Except a HOOK body's capability denial: that is a refusal naming + * the hook and the capability, so it carries this field and its `code`/ + * `status` (see `throwCapabilityDenial`). An action or job body's denial is + * still the fault described above. */ readonly innerMessage?: string; /** From 2f1daceca50dfb608759af174b5a2417e1d7410e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:29:07 +0000 Subject: [PATCH 3/5] test(dogfood): pin the hook capability refusal at the real data door Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu --- .../hook-capability-refusal.dogfood.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts diff --git a/packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts b/packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts new file mode 100644 index 00000000000..7a48d8ee396 --- /dev/null +++ b/packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts @@ -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 => { + 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)']); + }); +}); From 60488efacc2f0316084a00f4f297c6eb423fcad7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:42:56 +0000 Subject: [PATCH 4/5] test(runtime): run the hook capability refusal pin on SQLite; ratchet the unpinned-status baseline Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu --- ...ability-denial-is-a-refusal.integration.test.ts | 14 +++++++++++--- scripts/error-status-unpinned-baseline.json | 1 - 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts b/packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts index c3fca03e714..8a1f16c59c5 100644 --- a/packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts +++ b/packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts @@ -25,15 +25,18 @@ * - An ACTION body's denial keeps the #4431 fault contract * (`capability-denial-is-a-fault.test.ts`); this card ruled on hooks only. * - * Measured in composition — a real `ObjectQL`, a real driver, the real + * Measured in composition — a real `ObjectQL`, a real SQLite driver, the real * `QuickJSScriptRunner` and hook wrapper — and read off `mapDataError`, the * ADR-0112 table every data door answers with. */ import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { mapDataError } from '@objectstack/types'; import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqlDriver } from '@objectstack/driver-sql'; import { hookBodyRunnerFactory } from './body-runner.js'; import { QuickJSScriptRunner } from './quickjs-runner.js'; @@ -73,17 +76,22 @@ const GATES = [ describe('[#26903] a hook body\'s capability denial is a coded refusal', () => { let engine: ObjectQL | null = null; + let dir: string | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; + if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } }); async function boot(hook: Record) { const hookLog = recordingLogger(); const bodyLog = recordingLogger(); + dir = mkdtempSync(join(tmpdir(), 'os-hook-cap-26903-')); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true }); + await driver.initObjects([NOTE, LOG]); engine = new ObjectQL(); - engine.registerDriver(new InMemoryDriver() as any, true); + engine.registerDriver(driver as any, true); await engine.init(); for (const o of [NOTE, LOG]) engine.registry.registerObject(o as any); const bodyRunner = hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'cap', logger: bodyLog }); diff --git a/scripts/error-status-unpinned-baseline.json b/scripts/error-status-unpinned-baseline.json index e1c58436c74..6725f9f7ca2 100644 --- a/scripts/error-status-unpinned-baseline.json +++ b/scripts/error-status-unpinned-baseline.json @@ -7,7 +7,6 @@ "EXPIRED_TOKEN", "FIELD_NOT_ACCESSIBLE", "FIELD_NOT_FOUND", - "INSUFFICIENT_PRIVILEGES", "INTEGRATION_ERROR", "INVALID_CREDENTIALS", "INVALID_FORMAT", From a7f0a7f4a663b0abf0c327e82e46c3fd6226ec25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:40:56 +0000 Subject: [PATCH 5/5] test(core): the sandbox fault census reads the capability gates through their helper Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu --- .../import-runner-sandbox-fault-row.test.ts | 78 ++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/import-runner-sandbox-fault-row.test.ts b/packages/core/src/utils/import-runner-sandbox-fault-row.test.ts index 540583eceb2..217e1fcfd82 100644 --- a/packages/core/src/utils/import-runner-sandbox-fault-row.test.ts +++ b/packages/core/src/utils/import-runner-sandbox-fault-row.test.ts @@ -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'; @@ -92,6 +105,18 @@ const SAMPLES: Readonly> = { 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> = { + ...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> = { ...SAMPLES, @@ -105,7 +130,7 @@ const CRASH_SAMPLES: Readonly> = { * runner, or a new non-literal one, fails §1's census. */ const RELAYS: Readonly> = { - 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)', }; @@ -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 ?? []; @@ -208,9 +234,27 @@ 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}`); } @@ -218,9 +262,33 @@ const census = (() => { 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. */ @@ -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);