Skip to content

Commit 74327d3

Browse files
fix(runtime): a non-sandboxed crash at /api/v1/actions no longer ships its native error message verbatim (#18760)
Fixes #18540 Clause-②: no A non-sandboxed crash at `/api/v1/actions` shipped its native error message verbatim. The identical crash through the `/data` door was already sanitised. **The status was already right; what leaked was the sentence** — so only `error.message` moves here. No status code, no `error.code`, no envelope key. ## Premise 1 — reproduced first, on today's `origin/main` The card's own status note says the filing seat did not verify the measurement at the tree, and that the first act is to reproduce it. Driven through the real `HttpDispatcher.handleActions` door at base `e77a23f02`, with `mapDataError` driven on the same throw as the control: | | `/actions` (before) | `/data` | |---|---|---| | `new TypeError("Cannot read properties of undefined (reading 'id')")` | `500` `INTERNAL_ERROR` — message `Cannot read properties of undefined (reading 'id')` | `500` `INTERNAL_ERROR` — message `Internal server error` | Byte-for-byte the envelope the card recorded. `premise_still_valid: true`. ## Premise 2 — why #17273's terminal does not catch it Confirmed at the tree, not copied from the card. The terminal reads: ```ts const sandboxCrash = typeof inner === 'string' && inner.length > 0 && isNativeErrorName(inner.trim()); ``` `inner` is `err.innerMessage`, which only the QuickJS runner populates. An in-process registered handler never crosses the VM boundary, so `innerMessage` is absent, `sandboxCrash` is `false`, and the throw falls through to `unexpectedFault`. ⭐ The shape, carried into the code comment and the test docblock because it generalises: **a predicate that classifies by HOW a crash arrived is structurally blind to crashes that did not arrive that way — while looking exhaustive.** ## Premise 3 — the width, with controls in both directions Both doors driven on the same ten throws. Seven shapes leaked at `/actions` and every one of them was **already** sanitised at `/data`: | throw | `/actions` before | `/data` | |---|---|---| | `TypeError` | leaked the native sentence | `Internal server error` | | `ReferenceError` | `x is not defined` | `Internal server error` | | `RangeError` | `Maximum call stack size exceeded` | `Internal server error` | | `SyntaxError` | `Unexpected token }` | `Internal server error` | | driver class, prose the heuristic does NOT recognise | `database disk image is malformed at /srv/data/tenant_42.db` — a server **filesystem path** | `Internal server error` | | sandbox timeout | `action timed out after 5000ms` | `Internal server error` | | sandbox capability denial | `... capability 'api.read' not granted` | `Internal server error` | Controls in the other direction, unchanged by this PR: | throw | `/actions` | note | |---|---|---| | driver class, leaky prose | already `Internal server error` | the 5xx heuristic already fired | | plain `Error` — a deliberate rejection | `400` + its own sentence | the refusal channel, untouched | | `TypeError` carrying `status: 403` | `403 FORBIDDEN` + `Not allowed` | a declared answer, untouched | ⇒ the leaking population is **exactly** the `unexpectedFault` branch's population, at one site. The other dispatcher doors (`/meta`, `/mcp`) never make this classification at all, so their undeclared-5xx prose is #5667's recorded decision rather than this defect — noted below, not filed. ## The fix, and why it is not the heuristic The two doors differ **structurally**, not by one missing phrasing: - `/data` is **default-DENY**: `classifyDataError` ends in an unconditional `UNCLASSIFIED_FAULT()`. Its `looksLikeInternalErrorLeak` limb only chooses `DATABASE_ERROR` over `INTERNAL_ERROR` — that limb is not what sanitises. - the actions door was **default-ALLOW**: `unexpectedFault` relayed `err.message` through a 5xx withhold gated on `looksLikeInternalErrorLeak`, a driver-dump heuristic that reads FALSE for stack-shaped prose. `unexpectedFault` **is** this door's unclassified-fault terminal, so it now answers the terminal's envelope — `INTERNAL_ERROR_MESSAGE`, through the same `deps.error` seam #17273's terminal one branch above already uses. Both fences held: - ⛔ `looksLikeInternalErrorLeak` is **not** relaxed or re-pointed at stack-shaped prose. It guards a different question at every other boundary. - ⛔ `DomainHandlerDeps.error`'s seam is **not** widened. A fault's `userMessage` and its non-string `details.code` do not ride this exit, exactly as they do not ride `/data`'s — the same accepted difference #17273's terminal already carries. **The status provably does not move.** Reaching this branch already proves `.status`/`.statusCode` are absent (the branch above serves them) and that this is not a `ValidationError`. So in `resolveThrownHttpError` the `declaredStatus` chain is empty, `status` is the `500` fallback, and `code` is `standardErrorCodeForHttpStatus(500)` = `INTERNAL_ERROR` — the code this exit emits. The STOP-AND-REPORT condition did not trigger: no declared status code or envelope shape changes. ## Tests `packages/runtime/src/domains/actions-fault-vs-rejection.test.ts` — the file that already drove this exact door: - the pre-existing `a TypeError from a buggy handler` pin used the card's exact message and asserted status, `success` and `data` but **nothing about `message`** — so the live leak sat green underneath it. "Still 500" is exactly what the defect looked like. That pin now asserts the sentence too. - a new section adds both halves: two crashes whose prose the heuristic does not recognise, and the two controls one property away on either side (a rejection keeps its `400` and its words; a crash that declared its own status keeps both). ⚠️ **Tier measured first, in both directions, before and after.** `packages/runtime` has two vitest projects and `vitest.repo-tests.json` names exactly two files. The repo's own filter preflight answers: before, `local` ran the file (17 passed) and `repo` reported `FILTER SELECTED NOTHING ... lives in the local project`; after, `local` runs 21 and `repo` still selects nothing. The file did not move, and its 17 existing pins did not move with it. The `/data` parity is therefore asserted in **prose** rather than by importing `@objectstack/rest` into this file — a cross-package import is what would have moved it. Measured (`scripts/pm/os-verify-lock.sh`, shared box): | run | result | |---|---| | `pnpm --filter @objectstack/runtime test` (project `local`) | 265 files, **3665 passed**, 1 skipped — exit 0 | | `pnpm --filter @objectstack/runtime test:repo` | 2 files, **69 passed** — exit 0 (holds `error-envelope.conformance.test.ts`) | | `pnpm --filter @objectstack/runtime typecheck` | exit 0 | | `pnpm --filter '@objectstack/runtime^...' build` | exit 0 | | `pnpm lint` — repo-wide `eslint . --no-inline-config`, full population, no narrowing | exit 0 | ### Ablation — the pin is shown to go red when the leak is put back Fix committed first, so a restore point existed. Mutation **proven on disk before the run**, not from an editor's exit code: the target blob moved `5aa9ba6dd…` → `a0178be61…`, the leak anchor went 1 → 2 and the fixed anchor 2 → 1, at a printed offset. ⚠️ Recorded because it is the point of the proof: the **first** attempt was a silent no-op — a `perl -0777` one-liner died on a quoting error, changed nothing, and the anchor/blob check refused the run rather than reporting a green. The reading below is from the second, verified mutation. With the leak restored: **3 failed | 18 passed**, the three failures being exactly the disclosure assertions, carrying the real leaked strings — ``` AssertionError: expected 'Cannot read properties of undefined (…' to be 'Internal server error' AssertionError: expected 'database disk image is malformed at /…' to be 'Internal server error' ``` The four negative controls stayed green, which is what distinguishes this fix from a blanket sweep that would have eaten the refusal channel. Restore verified **by blob hash** — back to `5aa9ba6dd…`, `git diff HEAD` empty — ⛔ not by an exit code, under a `trap ... EXIT INT TERM` on an absolute path. ### Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`. The first derivation reported a **STALE TREE** (3 commits behind, and 2 files it derives from had changed); `origin/main` was merged and the list re-derived from the merged tree — byte-identical, 59 commands. **57 of 59 exit 0.** Reconciled with every exit code recorded. Also run because their `silent` verdict is a fact about a roster, not about these paths — 5 of them keep that roster in a directory one of these paths is in: `check:route-ledger-census`, `check:error-code-casing`, `check:authz-resolver`, `check:filter-alias-parity`, `check-changeset-fixed`, plus `check:error-status-conformance` and `check:dispatcher-error-vocabulary` as the two adjacent to this change. All exit 0 — the first confirms no status row moved, the second that the `error.code` vocabulary is intact. ⚠️ `pnpm lint` is run although `dispatch-gates.mjs` does not name the family — this lane's standing blind spot. **NOT MEASURED, declared rather than counted as a pass:** - `pnpm check:dual-build-cjs-loads` — `exit 3`, `PREREQUISITE NOT MET ... some package has no dist/`, naming ~36 unbuilt packages and `Run pnpm build first`. A whole-farm build; CI's `Build Core` + `Lint & Repo Gates` is where it is measured. ⛔ Not a pass and not a red. - `pnpm check:type-check-debt` first answered `exit 3` for the same class, naming one unbuilt dependency at a time. Both were built (`@objectstack/runtime`, then `@objectstack/driver-mongodb`) and it now reports `--re-measure: OK — 4 ledger entr(ies) re-measured, 53 raw tsc error(s) total, none above its recorded number` — so this one **is** measured. ### Docs-drift rider Predicate stated **before** reading: *a document states, about the `/api/v1/actions` door, that an unexpected fault's client-facing message is or contains the thrown error's own text.* Swept by symbol (`unexpectedFault`, `errorFromThrown`, `INTERNAL_ERROR`, crash) **and** by input shape (the wire body, and the leaked sentences themselves). **NOT FALSIFIED.** `content/docs/api/error-catalog.mdx`'s Action Errors row declares the status and the envelope — `Handler crashed (TypeError, driver error, sandbox timeout)` → `500` → `{ success: false, error: { message, code } }` — never that `message` carries the thrown text; and its `INTERNAL_ERROR` entry already reads *"An unexpected server-side error occurred. Report the issue with the requestId and traceId"*, which is what a withheld sentence means. Controls both ways: the same table's rejection rows **are** statements about the message channel, were found by the sweep, and describe branches this PR does not touch; the three `content/docs/**` hits for the native sentences are authoring-time validator prose, correctly not implicated. `content/docs/protocol/kernel/http-protocol.mdx`, which `main` moved during this change, was re-swept and says nothing about this surface. One in-repo statement **was** falsified and is corrected here: `packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts` said of a capability denial that *"what the client sees is this text, minus the prefix"*. Measured — that denial reaches `unexpectedFault`, so after this change the client sees `Internal server error`. Its assertions are all about the **thrown** error and are unchanged; only the justification is corrected to name the log as their subject. ## Changeset `patch` on `@objectstack/runtime`. ⛔ Not `skip-changeset`: the package publishes (`files: ["dist", ...]`) and the wire answer of a published HTTP surface moves. `Clause-②: no` — the diff adds or removes no schema key, closed-set member, published export or registry row, and an unexpected fault's message text is not a contract. Verified with the repo's own `readClause2Line`, ⛔ not a hand-written regex: `{"kind":"declared","value":"no","arm":null,"line":"Clause-②: no"}`, re-checked against the newer copy of that reader after the merge. ## Acceptance notes Out of scope for this card, ⛔ not filed and ⛔ not fixed here: - **The two doors still disagree on STATUS for a missing-relation throw.** A `SqliteError('no such table: crm_invoice')` answers `500 INTERNAL_ERROR` at `/actions` and `404 OBJECT_NOT_FOUND` at `/data`, whose classifier has a missing-relation attribution arm the dispatcher has no equivalent of. Measured here as a by-product. This is a **status** difference, and this card is explicitly fenced from moving a status. Successor: none of the open cards named in the dispatch faces it. - **The undeclared-5xx band at the other dispatcher doors.** `/meta` and `/mcp` hand everything to `errorFromThrown(e, 500)` without ever classifying a crash, so a `TypeError` there also ships its prose. That is not this defect: it is #5667's recorded decision to keep undeclared 5xx legible, guarded only by `looksLikeInternalErrorLeak`, and `resolveThrownHttpError`'s own docblock records #12281 as the separate card for the *declared* limb with its own measurement-first step. Changing it would reverse a recorded decision. Successor: #12281 for the declared half; the undeclared half has no card and is a decision, not an execution. - **A phantom-risk note, not a defect.** `deps.error`'s seam carries no `extra`, so this exit cannot pass `userMessage` even if a fault declared one. Reachability is near-nil (a fault that also declares user-facing text), the card fences the widening that would close it, and `/data` has the same gap — recorded so the next reader does not mistake it for an oversight. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04acdcc commit 74327d3

4 files changed

Lines changed: 227 additions & 6 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): a NON-sandboxed crash at `/api/v1/actions` no longer ships its native error message verbatim (#18540)
6+
7+
Clause-②: no
8+
9+
A plain `TypeError` thrown by an in-process registered action handler answered
10+
`500 INTERNAL_ERROR` carrying the native sentence on the wire:
11+
12+
```
13+
{"success":false,"error":{"code":"INTERNAL_ERROR",
14+
"message":"Cannot read properties of undefined (reading 'id')","httpStatus":500}}
15+
```
16+
17+
The identical crash through the `/data` door answered `"Internal server error"`
18+
(#7543 / #15071). One repository, two doors, one already meeting the contract.
19+
20+
**The status was already right; what leaked was the sentence.** No status code,
21+
no `error.code` and no envelope key moves — reaching this branch already proves
22+
the throw declared no `status`/`statusCode` (the branch above serves those) and
23+
is not a `ValidationError`, so the resolver's status was the 500 fallback and its
24+
code was the status-derived `INTERNAL_ERROR`. Only `error.message` changes.
25+
26+
**Why neither existing guard caught it.** #17273's crash terminal is keyed on the
27+
SANDBOX — `isNativeErrorName` read over the `innerMessage` the QuickJS runner
28+
fills — and this face never crosses a VM boundary, so nothing sets `innerMessage`
29+
and that terminal never fires. *A predicate that classifies by HOW a crash
30+
arrived is structurally blind to crashes that did not arrive that way, while
31+
looking exhaustive.* The other guard, the dispatcher's 5xx withhold, is gated on
32+
`looksLikeInternalErrorLeak`, which recognises DRIVER DUMPS and reads FALSE for
33+
stack-shaped prose.
34+
35+
**The structural difference, which is the fix.** The `/data` door is default-DENY:
36+
`classifyDataError` ends in an unconditional `UNCLASSIFIED_FAULT()`, and its
37+
`looksLikeInternalErrorLeak` limb only picks `DATABASE_ERROR` over
38+
`INTERNAL_ERROR` — that limb is not what sanitises. The actions door's
39+
`unexpectedFault` exit relayed `err.message` and was therefore default-ALLOW:
40+
prose shipped unless a heuristic recognised it. That exit is this door's
41+
unclassified-fault terminal, so it now answers the terminal's envelope —
42+
`INTERNAL_ERROR_MESSAGE`, through the same `deps.error` seam #17273's terminal
43+
uses.
44+
45+
⛔ `looksLikeInternalErrorLeak` is NOT re-pointed at stack-shaped prose. It guards
46+
a different question at every other boundary, and widening it would change what
47+
each of them withholds.
48+
49+
**Measured population.** Driven through the real `HttpDispatcher.handleActions`
50+
door against `mapDataError` on the same throws: seven shapes leaked at `/actions`
51+
and were already sanitised at `/data` — `TypeError`, `ReferenceError`,
52+
`RangeError`, `SyntaxError`, a driver class whose prose the heuristic does not
53+
recognise (this one shipped a server **filesystem path**), a sandbox timeout and
54+
a sandbox capability denial. All seven now answer the same sentence at both
55+
doors. Two controls are unchanged in both directions: a deliberate rejection
56+
keeps its `400` and its own words, and a crash that DECLARED its own status keeps
57+
that status and that sentence.
58+
59+
**Who is affected.** Any caller reading `error.message` off a `500` from
60+
`/api/v1/actions` to tell one crash from another. That text was never a contract
61+
— it is the thrown error's own prose — and the full text still reaches the
62+
operator: the `console.error` on the line above keeps it, the same
63+
"the client does not read it, the log keeps it" split `rest` already draws.

‎packages/runtime/src/domains/actions-fault-vs-rejection.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ describe('an unexpected FAULT is a 500', () => {
145145
// No `{success:true, data:{...}}` wrapper — this is the dispatcher's
146146
// error exit, so monitoring sees a 5xx.
147147
expect(response.body.data).toBeUndefined();
148+
// [#18540] …and the SENTENCE is withheld. This pin asserted the three
149+
// lines above and nothing about `message`, so the live leak sat green
150+
// underneath it — "still 500" is exactly what the defect looked like.
151+
// The disclosure half is pinned in full in its own section below.
152+
expect(response.body.error.message).toBe('Internal server error');
148153
});
149154

150155
it('a ReferenceError from a buggy handler', async () => {
@@ -314,3 +319,101 @@ describe('[#17273] a sandboxed body that CRASHED is a fault, not a rejection', (
314319
expect(response.body.error.message).toBe('Import failed with a TypeError in row 4');
315320
});
316321
});
322+
323+
/**
324+
* [#18540] A NON-sandboxed crash's native sentence is withheld — the face of
325+
* this question that needs no sandbox at all.
326+
*
327+
* #17273 put a crash terminal above every branch of this door that reads a
328+
* producer declaration as intent, but its predicate is keyed on the SANDBOX:
329+
* `isNativeErrorName` over the `innerMessage` the QuickJS runner fills. A plain
330+
* `TypeError` from an in-process registered handler never crosses a VM
331+
* boundary, so nothing sets `innerMessage`, that terminal never fires, and the
332+
* throw fell to `unexpectedFault` → `errorFromThrown`, which relays
333+
* `err.message`. Measured on the wire before this change:
334+
*
335+
* {"success":false,"error":{"code":"INTERNAL_ERROR",
336+
* "message":"Cannot read properties of undefined (reading 'id')","httpStatus":500}}
337+
*
338+
* The same crash through the `/data` door answered `"Internal server error"`
339+
* (#7543 / #15071). ⇒ the status was already right; what leaked was the
340+
* sentence.
341+
*
342+
* **The shape worth carrying: a predicate that classifies by HOW a crash
343+
* arrived is structurally blind to crashes that did not arrive that way —
344+
* while looking exhaustive.** Same family as a ratchet with no row for the
345+
* case, and as a slot whose third consumer nobody reached.
346+
*
347+
* The other guard misses it for a second, independent reason, and that is why
348+
* the fix is not a new phrasing: the dispatcher's 5xx withhold is gated on
349+
* `looksLikeInternalErrorLeak`, which recognises DRIVER DUMPS and reads FALSE
350+
* for stack-shaped prose. So the relay is DEFAULT-ALLOW, while `/data` is
351+
* default-DENY — `classifyDataError` ends in an unconditional
352+
* `UNCLASSIFIED_FAULT()`, and its `looksLikeInternalErrorLeak` limb only picks
353+
* `DATABASE_ERROR` over `INTERNAL_ERROR`. The fix answers this door's terminal
354+
* with the terminal's envelope; ⛔ it does not re-point the heuristic, which
355+
* guards a different question at every other boundary.
356+
*
357+
* The cases below are BOTH halves, because a disclosure pin that only asserts
358+
* the withheld case cannot tell a fix from a blanket sweep that ate the
359+
* refusal channel: two crashes whose text the heuristic does NOT recognise,
360+
* then the two controls one property away on either side. The `/data` parity
361+
* is deliberately asserted in prose rather than by importing
362+
* `@objectstack/rest` here — a cross-package import would move this file, and
363+
* the pins above it, into the `repo` vitest project.
364+
*/
365+
describe('[#18540] a NON-sandboxed crash is answered with the sanitised sentence', () => {
366+
it("the card's exact repro — a bare TypeError, no sandbox anywhere in the path", async () => {
367+
const response = await invoke(new TypeError("Cannot read properties of undefined (reading 'id')"));
368+
369+
// Unmoved: the status and the code were already right, and this card is
370+
// fenced from touching them.
371+
expect(response.status).toBe(500);
372+
expect(response.body.error.code).toBe('INTERNAL_ERROR');
373+
// The whole of the change: the sentence, matching what `/data` answers.
374+
expect(response.body.error.message).toBe('Internal server error');
375+
expect(String(response.body.error.message)).not.toContain('Cannot read properties');
376+
expect(response.body.success).toBe(false);
377+
});
378+
379+
it('a fault whose prose the leak heuristic does NOT recognise — a driver class naming a FILE PATH', async () => {
380+
// `looksLikeInternalErrorLeak` reads FALSE here: no SQL keyword, no
381+
// dialect template, nothing quoted. Before this change the tenant
382+
// received a server filesystem path. This case is why the fix cannot be
383+
// "teach the heuristic about TypeError" — the leaking population is not
384+
// a phrasing family, it is everything the terminal was relaying.
385+
const err: any = new Error('database disk image is malformed at /srv/data/tenant_42.db');
386+
err.name = 'SqliteError';
387+
const response = await invoke(err);
388+
389+
expect(response.status).toBe(500);
390+
expect(response.body.error.message).toBe('Internal server error');
391+
expect(String(response.body.error.message)).not.toContain('/srv/data');
392+
});
393+
394+
it('negative control: a deliberate rejection keeps its 400 AND its own sentence', async () => {
395+
// One `name` away from the first case. An implementation that withheld
396+
// every message at this catch — or that moved the fault terminal above
397+
// the rejection exit — would turn the cases above green while deleting
398+
// the channel a business rule speaks through.
399+
const response = await invoke(new Error('Lead is already converted'));
400+
401+
expect(response.status).toBe(400);
402+
expect(response.body.error.message).toBe('Lead is already converted');
403+
});
404+
405+
it('negative control: a crash that DECLARED its own status keeps that status and that sentence', async () => {
406+
// The branch serving `.status` sits above `unexpectedFault`, so this
407+
// throw never reaches the terminal. It is the control for the fence on
408+
// this card: ⛔ no declared status moves, and a producer that composed
409+
// an answer still speaks.
410+
const err: any = new TypeError('Not allowed');
411+
err.status = 403;
412+
err.code = 'FORBIDDEN';
413+
const response = await invoke(err);
414+
415+
expect(response.status).toBe(403);
416+
expect(response.body.error.code).toBe('FORBIDDEN');
417+
expect(response.body.error.message).toBe('Not allowed');
418+
});
419+
});

‎packages/runtime/src/domains/actions.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,58 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string
969969
&& !validationFailureDetails(err);
970970
if (unexpectedFault) {
971971
console.error(`[action ${objectName}/${actionName}] unexpected fault (${name}): ${full}`);
972-
return { handled: true, response: deps.errorFromThrown(err, 500) };
972+
// [#18540] This branch is this door's UNCLASSIFIED-FAULT TERMINAL, so
973+
// it answers the terminal's envelope — `INTERNAL_ERROR_MESSAGE`, the
974+
// same sentence `/data` answers — instead of relaying `err.message`.
975+
//
976+
// The status was already right; what leaked was the SENTENCE. A
977+
// non-sandboxed crash — a plain `TypeError` from an in-process
978+
// registered handler — reached the wire as `500 INTERNAL_ERROR`
979+
// carrying `Cannot read properties of undefined (reading 'id')`
980+
// verbatim, while the IDENTICAL throw through the `/data` door
981+
// answered `Internal server error`.
982+
//
983+
// Why neither existing guard caught it, both measured on this tree:
984+
//
985+
// - #17273's crash terminal above is keyed on the SANDBOX
986+
// (`isNativeErrorName` over the `innerMessage` the QuickJS runner
987+
// fills). This face never crosses a VM boundary, so nothing sets
988+
// `innerMessage` and that terminal never fires. A predicate that
989+
// classifies by HOW a crash arrived is structurally blind to
990+
// crashes that did not arrive that way — while looking exhaustive.
991+
// - `errorFromThrown` relays `err.message` through the dispatcher's
992+
// 5xx withhold, which is gated on `looksLikeInternalErrorLeak` —
993+
// a DRIVER-DUMP heuristic that reads FALSE for stack-shaped prose
994+
// (#17273's changeset records the same reading). So that relay is
995+
// DEFAULT-ALLOW: prose ships unless the heuristic recognises it.
996+
//
997+
// `/data` is default-DENY by construction: `classifyDataError` ends
998+
// in an unconditional `UNCLASSIFIED_FAULT()`, and its
999+
// `looksLikeInternalErrorLeak` limb only chooses `DATABASE_ERROR`
1000+
// over `INTERNAL_ERROR` — that limb is not what sanitises. Aligning
1001+
// therefore means answering the terminal here too, ⛔ never teaching
1002+
// the heuristic a new phrasing: re-pointing `looksLikeInternalErrorLeak`
1003+
// at stack-shaped prose would change what every OTHER boundary
1004+
// withholds, and it guards a different question.
1005+
//
1006+
// Nothing about the ANSWER moves but the sentence. Reaching here
1007+
// already proves `.status`/`.statusCode` are absent (the branch above
1008+
// serves them) and that this is not a `ValidationError`, so
1009+
// `resolveThrownHttpError` had no declared status either: its `status`
1010+
// was the 500 fallback and its `code` was
1011+
// `standardErrorCodeForHttpStatus(500)` — `INTERNAL_ERROR`, the code
1012+
// this exit emits. Same status, same code, same envelope shape.
1013+
//
1014+
// ⛔ Deliberately the SAME `deps.error` seam #17273's terminal uses,
1015+
// never a widened one: a fault's `userMessage` and its non-string
1016+
// `details.code` (a driver errno — the backend-naming disclosure
1017+
// `demotedDeclaredCode` already withholds on an undeclared 5xx) do not
1018+
// ride this exit, exactly as they do not ride `/data`'s.
1019+
//
1020+
// The words are not lost: the `console.error` above keeps the full
1021+
// text — the same "the client does not read it, the log keeps it"
1022+
// split #5437 draws in `rest`.
1023+
return { handled: true, response: deps.error(INTERNAL_ERROR_MESSAGE, 500) };
9731024
}
9741025

9751026
// [#3962] A deliberate REJECTION is a 400. The 200-with-inner-envelope

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,16 @@ describe('[#4431] an in-VM capability denial reaches the classifier as a FAULT',
6767
// invisible to gateway error rates, APM and alerting.
6868
expect(err.innerMessage).toBeUndefined();
6969

70-
// The debug prefix must not reach the client. The runner's own doc says
71-
// only the business message should — and a sandbox fault has none, so what
72-
// the client sees is this text, minus the prefix.
70+
// The debug prefix must not reach the OPERATOR'S LOG line either. Every
71+
// assertion in this block is about the THROWN error — what the runner hands
72+
// the classifier and what `domains/actions.ts` prints — never about the wire.
73+
// [#18540] Since the door's unexpected-fault terminal answers
74+
// `INTERNAL_ERROR_MESSAGE`, what the CLIENT sees for this denial is
75+
// `Internal server error`, the same sentence `/data` answers; this text is
76+
// the diagnostic half of that split, and it has to stay readable.
7377
expect(err.message).not.toContain('SandboxError:');
74-
// …while the actionable content survives: which capability, whose, and the
75-
// call that tripped the gate.
78+
// …while the actionable content survives for the log: which capability,
79+
// whose, and the call that tripped the gate.
7680
expect(err.message).toContain("capability 'api.read' not granted");
7781
expect(err.message).toContain("action 'rc1_crash_probe'");
7882
expect(err.message).toContain("ctx.api.object('showcase_task').count");

0 commit comments

Comments
 (0)