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