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
server: a hook refused for a missing capability answers the client 500 INTERNAL_ERROR instead of a coded refusal naming the hook and the capability #26903
Filed by seat domain:ui#1 (seat post #25966, session_01JQwxbZeBv2y91towxasSUT) as the server half of #26726, per triage first grade 6447036960 ("domain:server, a child card filed on claim"). ⛔ Not a claim; left for triage. Parent: #26726 (ui half: the Studio hook editor gains a capabilities control).
A hook whose sandboxed-JS body calls ctx.api.find(…) without capabilities: ["api.read"] is refused by the capability check ("capability 'api.read' not granted"). With onError: abort, the user's record save gets a bare 500 INTERNAL_ERROR. With onError: log, a refused ctx.log is swallowed and nothing is logged.
Expected
A capability refusal reaches the client as a coded 4xx that names the hook and the missing capability. With onError: log, the refusal itself is recorded where hook errors are logged.
Ruled
Triage 6447036960, verbatim: "⛔ The capability check stays as strict as it is." Only the error mapping changes.
Landing site (the implementer verifies on current main)
The server hook runner's error mapping for capability refusals (HookBodyCapability, packages/spec/src/data/hook-body.zod.ts, names the tokens). #26831 (open) adds a hook refusal catalogue on the spec side; check whether it already covers this before adding a code.
Filing facts
reach: /api/v1/data writes on any object with such a hook; measured by the studio-qa-2026-10-27 data gap-fill card (QA id G11).
Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5o5GCE3vGxq6G24dZu
Account: os-project-manager (the seat's linked user; the card's assignee)
Branch: claude/project-thread-ktf17y (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#2
Ruling-ref: triage 6453651751 (p2: ⛔ the capability check stays as strict as it is, only the error mapping and the log arm change; a new spec code goes back to triage); seat ruling: an existing declared code (or #26831's if merged), one mapping on the refusal side of the sandbox classifier, ⛔ no new code
File surface: the hook sandbox capability refusal and its HTTP / engine error mapping (packages/services/service-automation/**, the sandbox package, packages/runtime/** or packages/objectql/** where the hook error is mapped, found by the message), the onError: log sink, their tests, and a changeset per package. ⛔ No packages/spec/** (a needed code is a stop), ⛔ no Studio change (#26726), ⛔ no loosening of the capability check
Container & model: S, mode:thread, default model and effort (set at thread open)
Clause-②: no (likely: a 500 becomes a coded refusal; the dev re-measures and declares the arm)
Metadata-protocol: none
Responsibility: hook sandbox capability refusal mapping (server half of #26726)
Thread-read: 6453651751
Premise: holds (read 2026-10-27T22:01Z at origin/maine1ab32ed0b: card open, unassigned, pm:queue with its triage line; the split with server seat 1 gives this card to seat 2); the dev re-measures it first, and a refutation is a premise_still_valid: false hand-back
Resume-from: none — fresh from origin/main
Serial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before pr_create
State: pm:queue → pm:dispatched, assigned to os-project-manager, in this act.
os-dev-report
{
"issue": 26903,
"status": "done",
"branch": "claude/project-thread-ktf17y",
"pr": "#26963",
"session": "session_01FEkQ5o5GCE3vGxq6G24dZu",
"premise_still_valid": true,
"summary": "Abort half holds and is fixed; log half is refuted on main. In a HOOK body every capability gate (api.read, api.write, api.transaction, crypto.uuid, log, ctx.title(field)) now throws through one helper, throwCapabilityDenial (packages/runtime quickjs-runner.ts), which crosses the VM as a coded refusal (code INSUFFICIENT_PRIVILEGES, status 403, an existing StandardErrorCode member; no new code, no spec change; #26831 still open so none of its codes used). Every data door answers it through mapDataError's sandbox unwrap arm: 403 INSUFFICIENT_PRIVILEGES with the gate's text plus the remedy, naming the hook and the capability, nothing written. The gate is exactly as strict; action and job bodies keep the 500 fault contract. onError: log was measured live at e1ab32e: the denial is ALREADY logged (BodyRunner ERROR + the [hook] handler failed (onError=log; suppressing) line), so that half of the card did not reproduce; the PR pins that line (now with the remedy) and the save proceeding.",
"tests": "New packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts (real ObjectQL + SqlDriver better-sqlite3 + QuickJS + hook wrapper, read off mapDataError): 15/15 at 60488ef; with quickjs-runner.ts at its e1ab32e blob 5 failed / 10 passed, the 5 being exactly the onError: abort cases (expected 500 to be 403), log and control pass on main. New packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts (real stack, POST /data): 4/4; red leg with runtime rebuilt from the base blob (ablation-dist-preflight --absent ok) 1 failed / 3 passed, body {error: Internal server error, code: INTERNAL_ERROR} expected 500 to be 403; restored (blob hash + clean git status), rebuilt, preflight marker present, 4/4. Live os dev showcase probe POST /api/v1/data/probe1_note: main 500 INTERNAL_ERROR, branch 403 INSUFFICIENT_PRIVILEGES naming hook probe1_guard and api.read, records unchanged; control with api.read 201; log variant 201 and logged. runtime vitest src/sandbox + src/domains: 188 files, 4590 tests passed. runtime typecheck exit 0. eslint --no-inline-config packages/runtime + new dogfood file exit 0.",
"gates": "Local at 60488ef: dispatch-gates --commands --repo objectstack-ai/objectstack derived 71 families, 71 run, all exit 0 (codes captured before any pipe); --ran reconciliation 71 derived / 71 run / 0 NOT-MEASURED / 0 UNRUN, exit 0. Round 1 reds fixed in-PR: check:driver-memory-census (test moved from InMemoryDriver to SqlDriver, no ledger row) and check:error-status-conformance (INSUFFICIENT_PRIVILEGES ratcheted out of scripts/error-status-unpinned-baseline.json with --update). Full pnpm build exit 0 (116/116). CI check-runs on #26963: in_progress at report time, the seat reads them.",
"dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm; the one new dogfood file was run red and green as above",
"mcp_calls": "5 — read-only: issue_read get x2 (26903, 26726), issue_read get_comments x2 (26903), pull_request_read get x1 (26831); no MCP write tool",
"api_writes": "3 — git push of claude/project-thread-ktf17y (not REST); relay pr_create #26963 (fleet-write run 40601669954, read-back identical, assignee os-project-manager); this os-dev-report comment on #26903 via post-stamped.mjs",
"open_questions": [
{
"question": "Should an ACTION or JOB body's capability denial also become the coded 403 refusal? This PR keeps them on the #4431 fault contract (500) because the seat ruling named hook bodies only, so the same denial now answers 403 from a hook and 500 from an action.",
"options": [
"A: align actions and jobs on the same refusal in a follow-up card (one helper line: drop the origin.kind check, flip capability-denial-is-a-fault.test.ts)",
"B: keep the split; a capability denial in an action stays a fault visible to APM"
],
"recommendation": "A. Business need: an action author hits the same missing-grant mistake and gets the same opaque 500. Long-term: one rule for one gate, no per-origin answer. AI-error axis: a coded, located refusal with the remedy is what lets an AI author fix the metadata; a 500 hides it. Startup focus: it is a one-line change on an existing code, no new surface. The only cost is the #4431 APM argument, which the hook ruling already set aside."
}
],
"out_of_scope_findings": [
"class: a · reach: Studio record save on an object with such a hook answers 403 INSUFFICIENT_PRIVILEGES and the console's isPermissionError (ui/packages/react/src/utils/error-message.ts) shows its fixed no-permission line instead of the hook/capability sentence · evidence: memory-recorded UI 403 rule, not re-measured in the browser · carrier: #26726 (the UI half)",
"carrier: #26831 — overlap on quickjs-runner.ts; git merge-tree with head e559783 auto-merges, and its host-relay marker sits in hostErrorToVm which this denial passes through · noted, not filed",
"carrier: none — packages/types data-error-classification.ts comment cites a hook capability text as a sandbox own fault; still true for actions/jobs, outside the claim surface · noted in Acceptance notes only"
]
}
PR #26963 merged at 2026-10-28T01:56:24Z. The merge queue landed it as e46e25c0e1 on main (Fixes #26903; ACCEPT 6457859907; Clause-②: no). GitHub closed the card as completed.
Landed: a hook body that calls something its body.capabilities does not grant now answers the client 403 INSUFFICIENT_PRIVILEGES instead of 500 INTERNAL_ERROR. The answer names the hook and the capability, and says how to grant it. The check is as strict as before, and no record is written. The onError: 'log' arm already logged the denial; that line now carries the remedy too.
Activity
objectstack-fleet commentedon Oct 27, 2026
Triage:
domain:server, p2,area:automation· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-27T21:09Z⛔ Not a claim.
500 INTERNAL_ERROR, and withonError: lognothing is logged. The server half of 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 (dispatched, ui half), filed on that card's first grade 6447036960.protocol:data) adds a hook refusal catalogue on the spec side. The implementer reads whether it already gives a code for this refusal before adding one. If the fix needs a new spec code, that part is a protocol addition and goes back to triage for a p1 regrade.bug,pm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01FEkQ5o5GCE3vGxq6G24dZuAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-ktf17y(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2Ruling-ref: triage
6453651751(p2: ⛔ the capability check stays as strict as it is, only the error mapping and the log arm change; a new spec code goes back to triage); seat ruling: an existing declared code (or #26831's if merged), one mapping on the refusal side of the sandbox classifier, ⛔ no new codeFile surface: the hook sandbox capability refusal and its HTTP / engine error mapping (
packages/services/service-automation/**, the sandbox package,packages/runtime/**orpackages/objectql/**where the hook error is mapped, found by the message), theonError: logsink, their tests, and a changeset per package. ⛔ Nopackages/spec/**(a needed code is a stop), ⛔ no Studio change (#26726), ⛔ no loosening of the capability checkContainer & model:
S,mode:thread, default model and effort (set at thread open)Clause-②: no (likely: a 500 becomes a coded refusal; the dev re-measures and declares the arm)
Metadata-protocol: none
Responsibility: hook sandbox capability refusal mapping (server half of #26726)
Thread-read: 6453651751
Premise: holds (read 2026-10-27T22:01Z at
origin/maine1ab32ed0b: card open, unassigned,pm:queuewith its triage line; the split with server seat 1 gives this card to seat 2); the dev re-measures it first, and a refutation is apremise_still_valid: falsehand-backResume-from: none — fresh from
origin/mainSerial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before
pr_createState:
pm:queue→pm:dispatched, assigned toos-project-manager, in this act.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
os-dev-report
{
"issue": 26903,
"status": "done",
"branch": "claude/project-thread-ktf17y",
"pr": "#26963",
"session": "session_01FEkQ5o5GCE3vGxq6G24dZu",
"premise_still_valid": true,
"summary": "Abort half holds and is fixed; log half is refuted on main. In a HOOK body every capability gate (api.read, api.write, api.transaction, crypto.uuid, log, ctx.title(field)) now throws through one helper, throwCapabilityDenial (packages/runtime quickjs-runner.ts), which crosses the VM as a coded refusal (code INSUFFICIENT_PRIVILEGES, status 403, an existing StandardErrorCode member; no new code, no spec change; #26831 still open so none of its codes used). Every data door answers it through mapDataError's sandbox unwrap arm: 403 INSUFFICIENT_PRIVILEGES with the gate's text plus the remedy, naming the hook and the capability, nothing written. The gate is exactly as strict; action and job bodies keep the 500 fault contract. onError: log was measured live at e1ab32e: the denial is ALREADY logged (BodyRunner ERROR + the [hook] handler failed (onError=log; suppressing) line), so that half of the card did not reproduce; the PR pins that line (now with the remedy) and the save proceeding.",
"tests": "New packages/runtime/src/sandbox/hook-capability-denial-is-a-refusal.integration.test.ts (real ObjectQL + SqlDriver better-sqlite3 + QuickJS + hook wrapper, read off mapDataError): 15/15 at 60488ef; with quickjs-runner.ts at its e1ab32e blob 5 failed / 10 passed, the 5 being exactly the onError: abort cases (expected 500 to be 403), log and control pass on main. New packages/qa/dogfood/test/hook-capability-refusal.dogfood.test.ts (real stack, POST /data): 4/4; red leg with runtime rebuilt from the base blob (ablation-dist-preflight --absent ok) 1 failed / 3 passed, body {error: Internal server error, code: INTERNAL_ERROR} expected 500 to be 403; restored (blob hash + clean git status), rebuilt, preflight marker present, 4/4. Live os dev showcase probe POST /api/v1/data/probe1_note: main 500 INTERNAL_ERROR, branch 403 INSUFFICIENT_PRIVILEGES naming hook probe1_guard and api.read, records unchanged; control with api.read 201; log variant 201 and logged. runtime vitest src/sandbox + src/domains: 188 files, 4590 tests passed. runtime typecheck exit 0. eslint --no-inline-config packages/runtime + new dogfood file exit 0.",
"gates": "Local at 60488ef: dispatch-gates --commands --repo objectstack-ai/objectstack derived 71 families, 71 run, all exit 0 (codes captured before any pipe); --ran reconciliation 71 derived / 71 run / 0 NOT-MEASURED / 0 UNRUN, exit 0. Round 1 reds fixed in-PR: check:driver-memory-census (test moved from InMemoryDriver to SqlDriver, no ledger row) and check:error-status-conformance (INSUFFICIENT_PRIVILEGES ratcheted out of scripts/error-status-unpinned-baseline.json with --update). Full pnpm build exit 0 (116/116). CI check-runs on #26963: in_progress at report time, the seat reads them.",
"dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm; the one new dogfood file was run red and green as above",
"mcp_calls": "5 — read-only: issue_read get x2 (26903, 26726), issue_read get_comments x2 (26903), pull_request_read get x1 (26831); no MCP write tool",
"api_writes": "3 — git push of claude/project-thread-ktf17y (not REST); relay pr_create #26963 (fleet-write run 40601669954, read-back identical, assignee os-project-manager); this os-dev-report comment on #26903 via post-stamped.mjs",
"open_questions": [
{
"question": "Should an ACTION or JOB body's capability denial also become the coded 403 refusal? This PR keeps them on the #4431 fault contract (500) because the seat ruling named hook bodies only, so the same denial now answers 403 from a hook and 500 from an action.",
"options": [
"A: align actions and jobs on the same refusal in a follow-up card (one helper line: drop the origin.kind check, flip capability-denial-is-a-fault.test.ts)",
"B: keep the split; a capability denial in an action stays a fault visible to APM"
],
"recommendation": "A. Business need: an action author hits the same missing-grant mistake and gets the same opaque 500. Long-term: one rule for one gate, no per-origin answer. AI-error axis: a coded, located refusal with the remedy is what lets an AI author fix the metadata; a 500 hides it. Startup focus: it is a one-line change on an existing code, no new surface. The only cost is the #4431 APM argument, which the hook ruling already set aside."
}
],
"out_of_scope_findings": [
"class: a · reach: Studio record save on an object with such a hook answers 403 INSUFFICIENT_PRIVILEGES and the console's isPermissionError (ui/packages/react/src/utils/error-message.ts) shows its fixed no-permission line instead of the hook/capability sentence · evidence: memory-recorded UI 403 rule, not re-measured in the browser · carrier: #26726 (the UI half)",
"carrier: #26831 — overlap on quickjs-runner.ts; git merge-tree with head e559783 auto-merges, and its host-relay marker sits in hostErrorToVm which this denial passes through · noted, not filed",
"carrier: none — packages/types data-error-classification.ts comment cites a hook capability text as a sandbox own fault; still true for actions/jobs, outside the claim surface · noted in Acceptance notes only"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 28, 2026
Landed
2026-10-28T01:57Z ·
domain:serverseat 2 ·os-project-manager· sessionsession_0186vfjHHMsRXC9EUT9hTFMoPR #26963 merged at 2026-10-28T01:56:24Z. The merge queue landed it as
e46e25c0e1onmain(Fixes #26903; ACCEPT6457859907;Clause-②: no). GitHub closed the card as completed.git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26963-*'git merge-base --is-ancestor e46e25c0e1 origin/mainorigin/mainby contentpackages/runtime/src/sandbox/quickjs-runner.tsthrowCapabilityDenialbody.capabilitiesdoes not grant now answers the client403 INSUFFICIENT_PRIVILEGESinstead of500 INTERNAL_ERROR. The answer names the hook and the capability, and says how to grant it. The check is as strict as before, and no record is written. TheonError: 'log'arm already logged the denial; that line now carries the remedy too.Generated by Claude Code