Skip to content

Commit cea85fd

Browse files
os-salesclaude
andauthored
fix(runtime): a nested sandboxed hook refusal is a rejection, not a sandbox fault (#17679)
* fix(runtime): a nested sandboxed hook refusal is a rejection, not a sandbox fault A `beforeUpdate` hook that refuses a state transition for a business reason, reached through a script action's `ctx.api` write, answered `500 INTERNAL_ERROR` on `POST /actions/:object/:action` — the same refusal `/data` has answered `400` with the sentence verbatim since #11588. `domains/actions.ts` is not the producer. `hostErrorToVm` marked EVERY `SandboxError` crossing into the action body's VM as the sandbox's own fault (#4431) on an `instanceof` test — and a nested sandboxed hook's refusal is a `SandboxError`, wrapped by this same runner one level down. The pump branch that reads the marker then dropped `innerMessage`, `code`, `status` and `fields`, and the classifier correctly read that absence as a crash. The marker now asks the question `/data` asks — `sandboxBusinessMessage` (#11588), spelled in-package as `sandboxRefusalMessage` because `@objectstack/rest` re-exports nothing from `error-response` and importing it would widen that package's published surface. Both of its conditions travel: a capability denial has no business message and stays a fault, and a nested CRASH carries `TypeError: …` and stays a fault too, so neither side of the pinned fault/rejection line moves. Message-neutral by construction: the client-facing sentence is byte-identical to what the 500 already carried, because the flattened `SandboxError: ` name prefix is stripped on the rejection path by the same helper the fault path already used. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> * chore(changeset): 17265 nested hook refusal answers 4xx Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e9bba50 commit cea85fd

3 files changed

Lines changed: 354 additions & 4 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
A sandboxed hook's business refusal reached through a script action answers 4xx, not `500 INTERNAL_ERROR`
6+
7+
`POST /api/v1/actions/:object/:action` answered **`500 INTERNAL_ERROR`** when a
8+
`beforeUpdate` hook refused a state transition for a business reason and the
9+
refusal travelled out through the action body's `ctx.api` write. The same refusal
10+
has answered **`400`**, with the hook's sentence verbatim, on `/data` since
11+
objectstack#11588. A 500 tells every client "the platform broke", so a
12+
well-behaved one retries, alerts or pages for a guard that will never say yes.
13+
14+
**Where the producer was.** Not in the action route's classifier — that read the
15+
shape it was handed correctly, and both sides of the line it pins (`a deliberate
16+
REJECTION is a 400` / `an unexpected FAULT is a 500`) are unchanged. The refusal
17+
arrived already stripped of every mark that says "a body reported this on
18+
purpose", one VM hop earlier: `hostErrorToVm` marked **every** `SandboxError`
19+
crossing into the action body's VM as the sandbox's OWN fault (objectstack#4431)
20+
on an `instanceof` test — and a nested sandboxed hook's refusal *is* a
21+
`SandboxError`, wrapped by the same runner one level down. The pump branch that
22+
reads that marker then discarded `innerMessage`, `code`, `status` and `fields`,
23+
and the classifier read the missing business message as a crash.
24+
25+
**What changed.** The marker now asks the question the `/data` door asks —
26+
`sandboxBusinessMessage`, objectstack#11588 — instead of testing the error's
27+
class. Both of that predicate's conditions travel, because both are load-bearing:
28+
a capability denial carries no business message and stays a fault, and a nested
29+
body that **crashed** carries `TypeError: …` and stays a fault too.
30+
31+
**No status was picked for this route.** It matches what `/data` already answers
32+
for the same producer: the status the body declared, or `400` when it declared
33+
none. A refusal that declares `{ status: 409, code: 'RECORD_LOCKED' }` now
34+
reaches the caller as `409 RECORD_LOCKED` instead of losing both.
35+
36+
**The sentence a caller receives is byte-identical to what the 500 carried**
37+
this moves the status, not the prose. The flattened `SandboxError: ` name prefix
38+
is stripped on the rejection path by the same helper the fault path already used.
39+
40+
No authorable key, accept set or export surface moves; no consumer needs a
41+
change. Clients branching on 5xx to decide whether to retry will stop retrying
42+
these refusals.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#17265] A sandboxed hook's BUSINESS REFUSAL reached through a script
5+
* action's `ctx.api` write is a rejection, not the sandbox faulting.
6+
*
7+
* ## What was measured broken
8+
*
9+
* The card reports `POST /api/v1/actions/clm_contract/submit_contract`
10+
* answering `500 INTERNAL_ERROR` for a `beforeUpdate` hook that refused a state
11+
* transition with a written, user-facing sentence — the same refusal `/data`
12+
* has answered `400` with the sentence verbatim since #11588.
13+
*
14+
* `domains/actions.ts`'s classifier is NOT the producer: it reads the shape it
15+
* is handed correctly (`actions-fault-vs-rejection.test.ts` pins both sides of
16+
* that line and neither moves). The refusal arrives at it already stripped of
17+
* every mark that says "a body reported this on purpose", one VM hop earlier:
18+
*
19+
* 1. the sandboxed `beforeUpdate` hook refuses — its own runner wraps that as
20+
* `SandboxError("hook 'g' threw: <biz>", "<biz>")`, `innerMessage` SET;
21+
* 2. it travels out of `engine.update()` into the ACTION body's host call, so
22+
* `hostErrorToVm` marshals it INTO the action's VM — and marked every
23+
* `SandboxError` reaching it as {@link SANDBOX_FAULT_PROP}, the sandbox's
24+
* OWN fault (#4431), on an `instanceof` test;
25+
* 3. escaping the action body uncaught, the pump loop reads that marker and
26+
* throws a bare `SandboxError` — no `innerMessage`, no `code`, no
27+
* `status`, no `fields`;
28+
* 4. which is, by the #3951 contract, exactly a CRASH ⇒ `errorFromThrown(err,
29+
* 500)` ⇒ `500 INTERNAL_ERROR`.
30+
*
31+
* The marker's own sibling pin already named this risk — "a marker applied too
32+
* broadly would turn every failed write into a 500"
33+
* (`capability-denial-is-a-fault.test.ts`) — and measured it with a plain
34+
* `ValidationError`, which is not a `SandboxError` and so never tripped the
35+
* `instanceof`. A NESTED sandboxed refusal is.
36+
*
37+
* ## The line this file pins
38+
*
39+
* The discriminator is the one `/data` asks (`sandboxBusinessMessage`, #11588):
40+
* does the error carry a caller-addressed business sentence that is not a
41+
* script fault? A refusal does and stays a rejection; a capability denial and a
42+
* nested CRASH do not and stay faults. So `/data`'s answer and the action
43+
* route's answer are the same answer, per route and per status.
44+
*/
45+
46+
import { describe, it, expect, vi } from 'vitest';
47+
48+
import { HttpDispatcher } from '../http-dispatcher.js';
49+
import { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js';
50+
import type { ScriptContext, ScriptRunOptions } from './script-runner.js';
51+
52+
/** The sentence the consuming app's guard addressed to its end user. */
53+
const REFUSAL = 'A contract cannot be submitted without a version file';
54+
55+
const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000, actionTimeoutMs: 10_000 });
56+
const actionOpts: ScriptRunOptions = { origin: { kind: 'action', name: 'submit_contract' } };
57+
58+
/**
59+
* The shape a SANDBOXED `beforeUpdate` hook's deliberate refusal really has
60+
* when `engine.update()` hands it back to the action body's host call — built
61+
* by `quickjs-runner`'s own pump loop one level down, so `innerMessage` is set.
62+
*/
63+
function nestedHookRefusal(): SandboxError {
64+
return new SandboxError(`hook 'guard_contract_submit' threw: ${REFUSAL}`, REFUSAL);
65+
}
66+
67+
/** A `ctx.api.object(x).update(...)` host seam whose write the hook refuses. */
68+
function refusingApi(thrown: () => unknown) {
69+
return {
70+
object: (_n: string) => ({
71+
update: async () => { throw thrown(); },
72+
}),
73+
};
74+
}
75+
76+
function ctx(over: Partial<ScriptContext> = {}): ScriptContext {
77+
return { input: {}, ...over };
78+
}
79+
80+
/** Run a script action whose `ctx.api` write throws `thrown`, and return the escape. */
81+
async function escapeOf(thrown: () => unknown): Promise<any> {
82+
const err = await runner.runScript(
83+
{
84+
language: 'js',
85+
source: "return await ctx.api.object('clm_contract').update('c_1', { status: 'submitted' });",
86+
capabilities: ['api.write'],
87+
},
88+
ctx({ api: refusingApi(thrown) }),
89+
actionOpts,
90+
).then(() => null, (e) => e);
91+
expect(err, 'expected the action to reject, but the script resolved').toBeInstanceOf(SandboxError);
92+
return err;
93+
}
94+
95+
// ── the wire half ────────────────────────────────────────────────────────────
96+
97+
const scriptAction = {
98+
name: 'submit_contract',
99+
objectName: 'clm_contract',
100+
type: 'script',
101+
body: { language: 'js', source: 'return 1;', capabilities: ['api.write'] },
102+
};
103+
104+
/** The same dispatcher harness `actions-fault-vs-rejection.test.ts` uses. */
105+
function makeDispatcher(thrown: unknown) {
106+
const objectDef = { name: 'clm_contract', actions: [scriptAction] };
107+
const ql: any = {
108+
executeAction: vi.fn(async () => { throw thrown; }),
109+
getSchema: (n: string) => (n === objectDef.name ? objectDef : undefined),
110+
registry: { getObject: (n: string) => (n === objectDef.name ? objectDef : undefined), getItem: () => undefined },
111+
find: vi.fn(async () => [{ id: 'c_1', status: 'draft' }]),
112+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
113+
};
114+
const metadata: any = {
115+
load: vi.fn(async () => null),
116+
listObjects: vi.fn(async () => [objectDef]),
117+
getObject: vi.fn(async () => objectDef),
118+
};
119+
const kernel: any = {
120+
context: {
121+
getService: (n: string) =>
122+
n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : null,
123+
},
124+
};
125+
return new HttpDispatcher(kernel);
126+
}
127+
128+
async function wireAnswer(thrown: unknown) {
129+
const res: any = await makeDispatcher(thrown).handleActions(
130+
'/clm_contract/submit_contract/c_1',
131+
'POST',
132+
{},
133+
{ request: {}, environmentId: 'platform', executionContext: { userId: 'u1', systemPermissions: [] } } as any,
134+
);
135+
return res.response;
136+
}
137+
138+
describe('[#17265] a nested sandboxed hook refusal keeps its business message', () => {
139+
it("a beforeUpdate refusal reached through ctx.api.update is NOT the sandbox's own fault", async () => {
140+
const err = await escapeOf(nestedHookRefusal);
141+
142+
// THE defect: the marker was applied on `instanceof SandboxError`, so
143+
// the nested refusal came back as a bare fault — no business message at
144+
// all, which is exactly what the #3951 contract reads as a CRASH.
145+
expect(err.innerMessage, 'the hook refusal must survive as a business message').toBeDefined();
146+
expect(err.innerMessage).toContain(REFUSAL);
147+
// …and the action's own debug wrapper still identifies who threw, in
148+
// the log-only `.message`, which keeps the WHOLE chain.
149+
expect(err.message).toContain("action 'submit_contract' threw:");
150+
expect(err.message).toContain("hook 'guard_contract_submit' threw:");
151+
152+
// Message-NEUTRAL: this repair moves the status, not the sentence. The
153+
// client-facing text is byte-identical to what the 500 already carried
154+
// — the VM's `SandboxError: ` name prefix is a debug artefact and has
155+
// never reached the wire.
156+
expect(err.innerMessage).toBe(`hook 'guard_contract_submit' threw: ${REFUSAL}`);
157+
expect(err.innerMessage).not.toContain('SandboxError:');
158+
});
159+
160+
it('the wire answer matches /data: 400 VALIDATION_ERROR with the sentence', async () => {
161+
// /data answers this refusal `400` with `error.innerMessage` verbatim
162+
// (`error-response.ts`'s sandbox unwrap door, `declared ?? 400`). The
163+
// action route must not answer a second thing for one refusal.
164+
const response = await wireAnswer(await escapeOf(nestedHookRefusal));
165+
166+
expect(response.status).toBe(400);
167+
expect(response.body.error.code).toBe('VALIDATION_ERROR');
168+
expect(response.body.error.message).toContain(REFUSAL);
169+
});
170+
171+
it("a nested refusal's DECLARED status and code survive the hop", async () => {
172+
// The fault branch dropped the whole `__errorInfo` payload, not just
173+
// `innerMessage`, so a hook declaring `{ status: 409, code:
174+
// 'RECORD_LOCKED' }` lost both and was flattened to 500. `/data`
175+
// answers `declared ?? 400` for this producer (#9967); the action door
176+
// honours a declared status at its own first arm (#7867), so once the
177+
// classification is right the two agree without a second rule.
178+
const locked = () => {
179+
const e: any = new SandboxError(
180+
`hook 'guard_contract_submit' threw: ${REFUSAL}`,
181+
REFUSAL,
182+
{ code: 'RECORD_LOCKED', status: 409 },
183+
);
184+
return e;
185+
};
186+
const response = await wireAnswer(await escapeOf(locked));
187+
188+
expect(response.status).toBe(409);
189+
expect(response.body.error.code).toBe('RECORD_LOCKED');
190+
expect(response.body.error.message).toContain(REFUSAL);
191+
});
192+
});
193+
194+
describe('[#17265] the FAULT side of the #4431 contract is untouched', () => {
195+
it("a nested CRASH is still a fault — sandboxBusinessMessage declines it", async () => {
196+
// A hook that blew up arrives in the same shape with a native error
197+
// name inside `innerMessage`. `/data` answers the sanitised 500 for it
198+
// (#7543), so the action route must too: the business-message read is
199+
// what separates them, never the error's class.
200+
const crash = () =>
201+
new SandboxError(
202+
"hook 'guard_contract_submit' threw: TypeError: cannot read properties of undefined",
203+
'TypeError: cannot read properties of undefined',
204+
);
205+
const response = await wireAnswer(await escapeOf(crash));
206+
207+
expect(response.status).toBe(500);
208+
expect(response.body.error.code).toBe('INTERNAL_ERROR');
209+
});
210+
211+
it('a capability denial inside the action body is still a fault', async () => {
212+
// The #4431 case itself: the sandbox refused before user code ran, so
213+
// there is no business message to carry and the 500 must stand.
214+
const err = await runner.runScript(
215+
{
216+
language: 'js',
217+
source: "return ctx.api.object('clm_contract').count({});",
218+
capabilities: [],
219+
},
220+
ctx({ api: { object: (_n: string) => ({ count: (_f: unknown) => 1 }) } }),
221+
actionOpts,
222+
).then(() => null, (e: any) => e);
223+
224+
expect(err).toBeInstanceOf(SandboxError);
225+
expect(err.innerMessage).toBeUndefined();
226+
expect((await wireAnswer(err)).status).toBe(500);
227+
});
228+
});

packages/runtime/src/sandbox/quickjs-runner.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,19 @@ export class QuickJSScriptRunner implements ScriptRunner {
395395
// the 400 a denial used to get — and the client sees the capability
396396
// text without the `SandboxError: ` debug prefix.
397397
if (info?.sandboxFault) {
398-
throw new SandboxError(sandboxFaultMessage(String(errStr)));
398+
throw new SandboxError(withoutSandboxErrorPrefix(String(errStr)));
399399
}
400+
// [#17265] The `.message` wrapper keeps the WHOLE flattened chain for
401+
// the log — `action 'x' threw: SandboxError: hook 'g' threw: <msg>`
402+
// names every frame that refused. The client-facing sentence drops the
403+
// inner `SandboxError: ` token by the same rule the fault branch above
404+
// applies: the VM's name prefix is a debug artefact and never reached
405+
// the wire before a nested refusal stopped being classified as a
406+
// fault, so this repair moves the STATUS and leaves the sentence a
407+
// caller receives byte-identical to what it was.
400408
throw new SandboxError(
401409
`${args.origin.kind} '${args.origin.name}' threw: ${errStr}`,
402-
userFacingMessage(String(errStr)),
410+
userFacingMessage(withoutSandboxErrorPrefix(String(errStr))),
403411
info,
404412
);
405413
}
@@ -1291,7 +1299,19 @@ function hostErrorToVm(vm: QuickJSContext, err: unknown): QuickJSHandle {
12911299
}
12921300
// [#4431] Mark the sandbox's OWN faults so the pump loop can tell them
12931301
// apart from a user throw after the VM has flattened both to a string.
1294-
if (err instanceof SandboxError) {
1302+
//
1303+
// [#17265] …asked as a QUESTION about the error, never as a bare
1304+
// `instanceof`. A NESTED sandboxed body's refusal is a `SandboxError` too:
1305+
// the `beforeUpdate` hook that `engine.update()` dispatched underneath this
1306+
// body's `ctx.api` write was wrapped by this very runner one level down. So
1307+
// the type test marked a deliberate business refusal as the sandbox
1308+
// faulting, the pump branch that reads the marker then dropped its
1309+
// `innerMessage` (and its `code`/`status`/`fields`), and
1310+
// `domains/actions.ts` read that absence as a CRASH — answering
1311+
// `500 INTERNAL_ERROR` for a refusal `/data` has answered `400` with the
1312+
// sentence verbatim since #11588. {@link sandboxRefusalMessage} is that
1313+
// door's own question, so the two doors answer one refusal once.
1314+
if (err instanceof SandboxError && sandboxRefusalMessage(err) === undefined) {
12951315
const h = vm.true;
12961316
vm.setProp(errH, SANDBOX_FAULT_PROP, h);
12971317
}
@@ -1345,6 +1365,59 @@ function hostErrorToVm(vm: QuickJSContext, err: unknown): QuickJSHandle {
13451365
*/
13461366
const SANDBOX_FAULT_PROP = '__objectstackSandboxFault';
13471367

1368+
/**
1369+
* [#17265] The ECMA-262 native error constructors, plus SpiderMonkey's
1370+
* `InternalError` which QuickJS also raises — the third copy of one pattern,
1371+
* and deliberately a copy.
1372+
*
1373+
* `packages/rest`'s `isScriptFaultMessage` (`error-response.ts`) is the
1374+
* original and `packages/objectql`'s `isScriptCrash`
1375+
* (`hook-withheld-readonly-fault.ts`) already keeps its own, for the reason
1376+
* stated there: the importing package must not take a dependency on
1377+
* `@objectstack/rest` for a regex. This package's reason is one step narrower —
1378+
* `@objectstack/runtime` DOES depend on `@objectstack/rest`, but that package
1379+
* declares exactly one export subpath (`"."`) and re-exports nothing from
1380+
* `error-response`, so importing the predicate would mean WIDENING rest's
1381+
* published surface for an internal read.
1382+
*
1383+
* ⛔ Same deliberate omission of a bare `Error:` as both siblings: a body's
1384+
* plain `Error` is the documented way to AUTHOR a refusal, so it is never a
1385+
* crash.
1386+
*/
1387+
const NATIVE_ERROR_NAME_RE =
1388+
/^(?:Type|Reference|Range|Syntax|URI|Eval|Internal|Aggregate)Error(?::|$)/;
1389+
1390+
/**
1391+
* [#17265] The caller-addressed BUSINESS sentence a sandboxed body threw, or
1392+
* `undefined` when this error is not a body's deliberate refusal.
1393+
*
1394+
* This is `packages/rest`'s `sandboxBusinessMessage` (#11588) — the read the
1395+
* `/data` door and, since #11684, the `/analytics/dataset/query` door both make
1396+
* instead of open-coding a local opinion. Both of its conditions travel, in the
1397+
* same order, because both are load-bearing HERE:
1398+
*
1399+
* - a non-empty string `.innerMessage` — the sandbox's own mark for "user code
1400+
* threw this deliberately", and by {@link SandboxError}'s contract the thing
1401+
* a capability denial, a timeout and a marshalling failure all lack. Its
1402+
* absence is what keeps every #4431 case marked as a fault;
1403+
* - NOT a native error name (#7543). A nested body that CRASHED arrives in the
1404+
* identical shape carrying `TypeError: …`, which is an internal fault and
1405+
* not a sentence addressed to anyone. Dropping this half would turn a nested
1406+
* crash into a 400 and move the `an unexpected FAULT is a 500` line that
1407+
* `domains/actions-fault-vs-rejection.test.ts` pins.
1408+
*
1409+
* ⛔ A READ of the field the runner populated, never a pattern-strip of the
1410+
* `<kind> '<name>' threw:` wrapper off `.message` — the sibling's rule, for the
1411+
* sibling's reason: a plain error whose own prose contains `threw:` must not be
1412+
* rewritten.
1413+
*/
1414+
function sandboxRefusalMessage(error: unknown): string | undefined {
1415+
const inner = (error as { innerMessage?: unknown } | null | undefined)?.innerMessage;
1416+
if (typeof inner !== 'string' || !inner) return undefined;
1417+
if (NATIVE_ERROR_NAME_RE.test(inner.trim())) return undefined;
1418+
return inner;
1419+
}
1420+
13481421
/**
13491422
* [#4431] Throw a sandbox-internal fault OUT OF a host function so it reaches
13501423
* the VM carrying {@link SANDBOX_FAULT_PROP}.
@@ -1371,8 +1444,15 @@ function throwSandboxFault(vm: QuickJSContext, message: string): never {
13711444
* for a sandbox fault there is no business message at all, so what reaches the
13721445
* client is this text — the capability, the origin and the call that tripped
13731446
* the gate — with the debug prefix removed.
1447+
*
1448+
* [#17265] Named for the OPERATION rather than for one of its callers, because
1449+
* it now has two: the same flattened prefix appears on the REJECTION path once
1450+
* a nested body's refusal stops being marked a fault, and the prefix belongs in
1451+
* the log there for exactly the same reason. One strip, two readers — the
1452+
* alternative was a second helper doing the same thing, which is the
1453+
* local-opinion shape this card exists to remove.
13741454
*/
1375-
function sandboxFaultMessage(raw: string): string {
1455+
function withoutSandboxErrorPrefix(raw: string): string {
13761456
return raw.startsWith('SandboxError: ') ? raw.slice('SandboxError: '.length) : raw;
13771457
}
13781458

0 commit comments

Comments
 (0)