Skip to content

fix(runtime): an action or job body refused for a missing capability answers the same coded 403 a hook body does, not a bare 500 - #27045

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-5mniaj
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-5mniaj

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26965

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:action 或 job 的沙箱 body 调用了没授予的能力(如没声明 api.read 却调 ctx.api.object('x').find)时,POST /api/v1/actions/对象/动作 不再回 500 Internal server error,而是和 hook 一样回 403 INSUFFICIENT_PRIVILEGES,消息点名是哪个 action/job、缺哪个能力,并提示加到「该 action/job 自己的」body.capabilities。
  • 为什么改:fix(runtime): a hook refused for a missing capability answers a coded 403 naming the hook and the capability, not a bare 500 #26963 之后同一道能力门对 hook 回 403、对 action 回 500,一道门两种答案;分诊裁决 A:一道门一个答案。
  • 风险与回滚:能力检查本身一点没放松,被拒的调用照样不执行、不写数据;只改拒绝的作答方式。job 没有 HTTP 调用方,运行记录的 error 存这句话,error 日志行保留给运维。回滚即 revert 本 PR。
  • 先看:packages/runtime/src/sandbox/quickjs-runner.ts 的 throwCapabilityDenial;action-job-capability-denial-is-a-refusal.integration.test.ts;packages/core/src/utils/import-runner-sandbox-fault-row.test.ts 的 §4。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/sandbox/action-job-capability-denial-is-a-refusal.integration.test.ts,预期 16 项全过(5 个能力 × action / job,加对照)。
  • 需要你做的:无,席位落地。

What changes

  • @objectstack/runtime, quickjs-runner.ts, throwCapabilityDenial: the first statement if (origin.kind !== 'hook') throwSandboxFault(vm, message); is gone, so every origin's denial crosses the VM as the coded refusal the hook arm already raised (code: 'INSUFFICIENT_PRIVILEGES', status: 403). The remedy names the owner from the origin, in that one template: Grant it by adding 'api.read' to the action's body.capabilities. (or hook's, job's). No per-origin builder; the two constants lose their HOOK_ prefix (CAPABILITY_REFUSAL_CODE / _STATUS, module-private). The capability check itself is untouched.
  • Docblocks that said a capability denial is the sandbox's own fault (SandboxError.innerMessage, SANDBOX_FAULT_PROP, ErrorInfo.sandboxFault, the pump loop's fault branch) now say it is a refusal for every origin; the fault marker keeps the other host-side faults.
  • Tests (runtime):
    • capability-denial-is-a-fault.test.ts is renamed capability-denial-is-a-refusal.test.ts and its pin flipped: each in-VM gate under an action origin hands the classifier innerMessage = the refusal sentence (no threw: wrapper, no SandboxError: prefix), code: INSUFFICIENT_PRIVILEGES, status: 403; one new case for a job origin. The "denial the body catches and rethrows" and rejection-side cases are unchanged.
    • New action-job-capability-denial-is-a-refusal.integration.test.ts (16): a real ObjectQL on SqlDriver (better-sqlite3), the real QuickJSScriptRunner, the real actionBodyRunnerFactory / jobBodyRunnerFactory, and the real /actions door (HttpDispatcher.handleActions). Per token: the action answers 403 INSUFFICIENT_PRIVILEGES naming action, capability and remedy, nothing written, one info refusal line and no error line; the job rejects with the same coded refusal and keeps its error line; controls with the capability run.
    • nested-hook-refusal-is-a-rejection.test.ts: the "capability denial inside the action body is still a fault" case is flipped to the 403 through the real door.
    • actions-fault-vs-rejection.test.ts and three docblocks: wording only (a timeout is still the sandbox's own fault; a capability denial is no longer named as one).
  • Census (@objectstack/core test:repo): import-runner-sandbox-fault-row.test.ts is migrated to the new rule, still derived from the producer and with no hand-written site list. The throwCapabilityDenial call sites leave the fault population (the capability denial fault kind is removed, and §1 now asserts no fault site reads as a capability denial). The helper is read from its body: it must not reach throwSandboxFault, its new Error(…) template is rendered per gate, and its code / status are read from the Object.assign (resolving the module constants). New §4 renders every gate under each origin kind (hook, action, job) and holds the door and the import row.
  • Dogfood: new action-capability-refusal.dogfood.test.ts (2) through the real stack's POST /api/v1/actions/acr_note/ACTION.
  • Changeset: @objectstack/runtime patch.

Why

Triage ruling A on #26965 (6456639416): one gate, one answer. After #26963 a hook body's capability denial answered 403 INSUFFICIENT_PRIVILEGES naming the hook and the capability, while an action body's same call answered 500 INTERNAL_ERROR "Internal server error", telling neither the caller nor the author which action or which capability. The #4431 argument (an action's own denial is a bug for the operator) was set aside for hooks on #26903 and nothing in it is specific to hooks. The operator signal stays server-side: an action's refusal is logged once at info by the BodyRunner ([BodyRunner] sandboxed action refused, with code and status) and the /actions door's console.error line keeps the full wrapper; a job keeps its error line.

Acceptance notes

  • Import row, measured (census §4). A coded 403 stays a row error: { ok: false, action: 'failed', error: SENTENCE (the refusal sentence), code: 'INSUFFICIENT_PRIVILEGES' }. Before, the action-origin rendering the census held read error: 'Internal server error', code: 'IMPORT_ROW_FAILED'. The ctx.api.object('OBJ') gate names the import's own object, and the door answers it 403, not the unknown-object heuristic's 404 (mapDataError reads the refusal's code before any message limb). An import fires hooks only, so in practice this is the hook row fix(runtime): a hook refused for a missing capability answers a coded 403 naming the hook and the capability, not a bare 500 #26963 already produced; the census now pins it.
  • Job. A job reaches no HTTP caller. Its handler rejects with the coded refusal; DbJobAdapter stores err.message as the run's error (db-job-adapter.ts, finishRun(runId, 'failed', …)), which now reads job 'J' threw: SandboxError: capability 'X' not granted to job 'J'. Grant it by adding 'X' to the job's body.capabilities. The BodyRunner's [BodyRunner] sandboxed job threw line stays at error.
  • Clause-②: no, measured. No call that ran before is refused: the denied call was refused before and is refused now (the gate is the same code path), and every control with the capability passes. What moves is the answer for an already-refused call: 500 INTERNAL_ERROR becomes 403 INSUFFICIENT_PRIVILEGES, and the log-only .message gains the action 'X' threw: wrapper the refusal relay uses.
  • Overlap with feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language #26831 (open, touches quickjs-runner.ts): git merge-tree --write-tree of this head with its head a1dbd6ea exits 0 with no conflict. Its catalogue resolver is hook-only and passes host-relayed errors through, so an action or job denial does not meet it.
  • Comments outside the claim's file surface still say a capability denial is the sandbox's own fault (stale since fix(runtime): a hook refused for a missing capability answers a coded 403 naming the hook and the capability, not a bare 500 #26963 for hooks, and now for every origin): packages/types/src/data-error-classification.ts (~786–792, ~1382), packages/core/src/utils/import-runner.ts ~522, packages/rest/src/error-response.ts ~708, external-datasource-routes.ts ~450, package-routes.ts ~239, sharing-routes.ts ~429. packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts keeps a hand-built sandboxOwnFault("capability 'api.read' not granted …") row; it still passes because it pins the classifier on a synthesised shape, which the producer no longer emits for a capability denial. Comments and a synthetic row only, no behaviour; not touched here (carrier: none).
  • The console's display of the 403 is Studio hooks: a hook cannot be granted capabilities in the Studio editor, and a hook refused for a missing capability answers the client 500 INTERNAL_ERROR instead of a located 4xx #26726's, not this card's.

Verification

At head af5816664b, branched from origin/main 4bce21da (which carries #26963 as e46e25c0). Every build and test ran under os-verify-lock.

  • Red first (the new integration file, before the fix, on 4bce21da): 10 failed, 6 passed of 16. The 10 are exactly the five action and five job denials; the action arm answered {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error","httpStatus":500}}: expected 500 to be 403, the job arm expected undefined to be 'INSUFFICIENT_PRIVILEGES'. The six controls passed.
  • Ablation (the fix committed first; scripts/ablation-replace.mjs put the removed if (origin.kind !== 'hook') throwSandboxFault(vm, message); back, runtime rebuilt, ablation-dist-preflight confirmed the marker in dist/; restored to the HEAD blob e31f4067, git diff HEAD empty, rebuilt, preflight --absent confirmed):
    • runtime pins (the new integration file, the flipped capability-denial-is-a-refusal.test.ts, nested-hook-refusal-is-a-rejection.test.ts): 16 failed, 13 passed of 29; the 16 are every denial case.
    • dogfood file: 1 failed, 1 passed (expected 500 to be 403, the body Internal server error); restored: 2 of 2.
    • core census: 1 failed of 40 (throwCapabilityDenial reaches throwSandboxFault: expected true to be false).
  • pnpm --filter @objectstack/runtime test: 466 files, 7597 passed, 19 skipped. pnpm --filter @objectstack/runtime typecheck: exit 0 (tsc + check:test-typecheck OK).
  • vitest run --project repo for every package with a vitest.repo-tests.json except spec: core 94, objectql 5, rest 280 (18 skipped), runtime 948, types 11, all passed. Spec's repo project (about 16 min) is left to CI's Test Core.
  • pnpm --filter @objectstack/dogfood exec vitest run test/action-capability-refusal.dogfood.test.ts: 2 of 2, after building the dogfood closure. Live log line from it: INFO [BodyRunner] sandboxed action refused {"action":"acr_count_denied","code":"INSUFFICIENT_PRIVILEGES","status":403,...}.
  • Dispatch gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 66 families on this diff; all 66 were run with each exit code captured before any pipe, and all 66 exited 0. Two first exited 3 (PREREQUISITE NOT MET, nothing measured): check-plugin-teardown-shape.mjs --self-test on the shallow clone (fetched its pinned fixture commit, then 48 cases pass) and check:dual-build-cjs-loads before a full pnpm build (built, then 112 entry points across 69 packages load). --ran reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, exit 0.
  • eslint --no-inline-config on packages/runtime, the census file and the new dogfood file: exit 0. check-commit-card-trailers --range origin/main..HEAD: exit 0. check-platform-checklist --changed-from origin/main: exit 0 (no anchored files).
  • Dogfood: no full run owed (no !, no narrowing arm).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5opKoaTFmNRbYvKobt


Generated by Claude Code

…answers the same coded 403 a hook body does, not a bare 500

One gate, one answer: throwCapabilityDenial no longer sends an action or
job origin to throwSandboxFault. The remedy names the body's own owner
from the origin kind. The capability check is unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5opKoaTFmNRbYvKobt
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via INSUFFICIENT_PRIVILEGES (literal, a string literal in CAPABILITY_REFUSAL_CODE; a string literal in HOOK_CAPABILITY_REFUSAL_CODE))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json cb8988ee149ae20fc6017793451098d83d2228a8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 29d8a6a3ae6bf123e4c5c930c9e1b069ac73d057 — the merge of head af5816664b56471a8dedf5150c419901a3c0bef2 into base cb8988ee149ae20fc6017793451098d83d2228a8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 29d8a6a3ae6bf123e4c5c930c9e1b069ac73d057 && git checkout 29d8a6a3ae6bf123e4c5c930c9e1b069ac73d057
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cb8988ee149ae20fc6017793451098d83d2228a8 af5816664b56471a8dedf5150c419901a3c0bef2 && git checkout -B drift-repro cb8988ee149ae20fc6017793451098d83d2228a8 && git merge --no-ff af5816664b56471a8dedf5150c419901a3c0bef2

node scripts/docs-audit/affected-docs.mjs --json cb8988ee149ae20fc6017793451098d83d2228a8

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs cb8988ee149ae20fc6017793451098d83d2228a8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-28T03:23Z · head af5816664b · #26965

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main, assignee os-project-manager. The body carries Fixes #26965, with Clause-②: no and Metadata-protocol: none line-initial. The changeset (@objectstack/runtime patch) repeats both.
    • check-governed-merges --pr 27045 reads NOT governed, 743 changed lines.
    • The claim (6457902576) names this PR's branch.
  2. Against triage ruling A (6456639416) and the order:
    • throwCapabilityDenial drops its origin check. Every origin's capability denial is now the same coded refusal: INSUFFICIENT_PRIVILEGES, an existing catalog code, at its 403. The check is as strict as before; only the answer's mapping changed.
    • The remedy names the body's own owner from origin.kind, in the one template. ScriptOrigin.kind is 'hook' | 'action' | 'job', so the sentence reads "the hook's", "the action's" or "the job's" body.capabilities.
    • No new code, no packages/spec/**, no ui/**.
    • An action's refusal is logged once at info. A job reaches no HTTP caller: its run records the sentence, and its error log line stays.
  3. The pins:
    • capability-denial-is-a-fault.test.ts is renamed -is-a-refusal and flipped.
    • The action-denial case of nested-hook-refusal-is-a-rejection now expects the real door's 403.
    • Core's import-runner-sandbox-fault-row.test.ts census is migrated from the producer, with no hand-written list. Capability gates leave the fault population, the helper is read for its template and constants, and a new section holds the door 403 and the import row for every gate × hook/action/job.
    • The dev measured the import row: it stays a row error, carrying the refusal sentence and the code.
  4. Red first and ablation (the dev's):
  5. Tests: runtime 7,597 passed and 19 skipped. Five repo projects pass. The new dogfood file passes 2 of 2. Type-check and eslint exit 0.
  6. Gates:
    • Locally 66 of 66 derived families exit 0 on af5816664b, with 0 NOT-MEASURED.
    • At this stamp every check-run on the head reads success or skipped: 38 success, 9 skipped, none failed or in progress. GitHub reads the PR clean.
  7. Overlap: git merge-tree reports no conflict against main cb8988ee14.
  8. Out of scope, noted and not filed (class: none): comments outside the claim surface still call a capability denial the sandbox's own fault (types data-error-classification, core import-runner, several rest routes). So does a hand-built sandboxOwnFault capability row in a rest ledger test that the producer no longer emits. These are comment and test-row drift with no behaviour, recorded in the PR's Acceptance notes.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants