Repository navigation
fix(runtime): a hook refused for a missing capability answers a coded 403 naming the hook and the capability, not a bare 500 - #26963
Conversation
…usal, not a 500 fault Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu
…ract, add changeset Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu
… the unpinned-status baseline Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d774add4c32c65128504ecffef9eebebf4ab0cd6 && git checkout d774add4c32c65128504ecffef9eebebf4ab0cd6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd246c22c90fc6604824b8e0d3d93d480e4d1f40 a7f0a7f4a663b0abf0c327e82e46c3fd6226ec25 && git checkout -B drift-repro fd246c22c90fc6604824b8e0d3d93d480e4d1f40 && git merge --no-ff a7f0a7f4a663b0abf0c327e82e46c3fd6226ec25
node scripts/docs-audit/affected-docs.mjs --json fd246c22c90fc6604824b8e0d3d93d480e4d1f40
|
…gh their helper Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5o5GCE3vGxq6G24dZu
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26903
Clause-②: no
Metadata-protocol: none
维护者速读
api.read却调ctx.api.find)时,保存记录不再回500 INTERNAL_ERROR,而是回403 INSUFFICIENT_PRIVILEGES(标准目录已有的码,未新增任何码),消息点名是哪个 hook、缺哪个能力、怎么补。capabilitiesin the Studio editor, and a hook refused for a missing capability answers the client 500 INTERNAL_ERROR instead of a located 4xx #26726 的服务端一半)。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).METHODforapi.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 carryingcode: 'INSUFFICIENT_PRIVILEGES'andstatus: 403, exactly the shape a body's ownthrow Object.assign(new Error(…), { code, status })has. The pump loop then builds the sameSandboxError(withinnerMessage,code,status) it builds for that throw, andmapDataError'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.throwSandboxFaultas before, so those keep the actions: sandbox capability denial answers 400 as a deliberate rejection, not the 500 the crash contract promises #4431 fault contract (500). The seat ruling covered hook bodies only.scripts/error-status-unpinned-baseline.json:INSUFFICIENT_PRIVILEGESleaves the unpinned list, because a producer now declares its status (check:error-status-conformance --update, a ratchet-down).SandboxError.innerMessage, the pump loop's fault branch andcapability-denial-is-a-fault.test.tssay a hook denial is the exception.@objectstack/runtimepatch.Why
A hook whose body calls
ctx.apiwithout 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 answered500 INTERNAL_ERRORwithInternal 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): realObjectQL, realSqlDriver(better-sqlite3), realQuickJSScriptRunnerand hook wrapper, read offmapDataError. For each of the five tokens:onError: abort:403,code: INSUFFICIENT_PRIVILEGES, the sentence names the hook, the capability and the remedy, carries neitherthrew:norSandboxError, no row is written, and the BodyRunner logs oneinforefusal line with noerrorline.onError: log: the save proceeds and the[hook] handler failed (onError=log; suppressing)line names the hook, object, event and the capability.packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts(new, 4): the real stack throughPOST /data/OBJECT(abort →403 INSUFFICIENT_PRIVILEGESnaming hook andapi.read, nothing written;log→ saved; control → saved and the body's read ran).Acceptance notes
onError: loghalf of the card did not reproduce on current main. Ate1ab32edthe denial is already logged underlog, twice atERROR(liveos devlog below; the compositionlogcases pass on main too). What the QA run likely read as "nothing is logged" is the body's ownctx.log.info(…)line, which the gate correctly refuses withoutlog. This PR keeps the hook-error line (now with the remedy) and pins it. Underlogthe BodyRunner line moves fromERRORwith a stack to theinforefusal line, like every other hook refusal.INSUFFICIENT_PRIVILEGES("Operation requires higher privileges") is aStandardErrorCodemember. It is notPERMISSION_DENIED, which reads as the caller lacking a permission and whose[Security] Access deniedprefix matcher would flatten it. No ledger row and no spec change. feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language #26831 is still open and unmerged, so no code from it was used.isPermissionErrortreats every 403 as its fixed "no permission" line, so the Studio/record toast does not show this sentence until a UI card readscode. That belongs with Studio hooks: a hook cannot be grantedcapabilitiesin the Studio editor, and a hook refused for a missing capability answers the client 500 INTERNAL_ERROR instead of a located 4xx #26726 (the UI half). No UI change here.500for a capability denial (capability-denial-is-a-fault.test.ts, unchanged). Aligning them is a separate decision, raised in the report.quickjs-runner.tsandbody-runner.ts):git merge-treewith its heade5597836auto-mergesquickjs-runner.ts. Semantically its host-relay marker is set inhostErrorToVm, which this denial goes through, so a hook with arefusalscatalogue passes the 403 through unchanged.packages/typeskeeps 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 fromorigin/maine1ab32ed. Every build and test ran underos-verify-lock.Dispatch gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 71 families on this diff. All 71 were run with each exit code captured before any pipe, and all 71 exited 0.--ranreconciliation: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN, exit 0.2f1dacec) had two reds, both fixed:check:driver-memory-census(the composition test first usedInMemoryDriver; it now usesSqlDriveron better-sqlite3, no ledger row added) andcheck:error-status-conformance(INSUFFICIENT_PRIVILEGESis now status-pinned by a producer; the baseline was ratcheted down with--update).Full
pnpm buildbefore the gates: exit 0 (116 of 116 tasks).Live red/green through
POST /api/v1/data(pnpm dev -- --fresh, showcase, a writable packageapp.probe1, objectprobe1_note, hookprobe1_guardonbeforeInsert, body readsctx.api.object('probe1_note').find({})):main (runtime rebuilt from the base blob)
this branch
quickjs-runner.tsset to itse1ab32edblob (committed fix first, restored withgit checkout HEAD --and checked by blob hash and a cleangit status):onError: abortcases (expected 500 to be 403). Thelogand control cases pass on main, which is the measured reading above.ablation-dist-preflight --absentconfirmed the marker gone fromdist/): 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 indist/, 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-configonpackages/runtimeand the new dogfood file: exit 0.!, no narrowing arm); the one new dogfood file was run as above.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code