Skip to content

actions: sandbox capability denial answers 400 as a deliberate rejection, not the 500 the crash contract promises #4431

Description

@baozhoutao

Found while browser/API-verifying the 17.0.0-rc.1 checklist on #3909 (F1, "failures speak HTTP"). Verified on main @ 1ee48bc60 against a running showcase (os serve --dev, workspace CLI).

What happens

A script action body that calls a ctx.api method without the required capability answers:

POST /api/v1/actions/showcase_task/rc1_crash_probe
→ HTTP 400
{"success":false,"error":{"code":"VALIDATION_ERROR","message":"SandboxError: capability 'api.read' not granted to action 'rc1_crash_probe' (called ctx.api.object('showcase_task').count)","httpStatus":400}}

Two things are wrong with that wire:

  1. It should be a 500. The action-crash-vs-rejection changeset (fix(actions): an action that CRASHED is a 500, not a 200 reporting success:false (#3913 follow-up) #3951, shipped in this train) pins the contract: "SandboxError with no innerMessage — timeout, capability denial → crash → 500". A capability denial is not an outcome the action chose to report; served as 400 it stays invisible to gateway error rates / APM / alerting — exactly the blindness fix(actions): an action that CRASHED is a 500, not a 200 reporting success:false (#3913 follow-up) #3951 was written to close.
  2. The debug prefix leaks. The client-facing message carries the SandboxError: name prefix the sandbox uses for server logs; the runner's own doc says only the business innerMessage should reach the client.

Repro: author any script action whose body.capabilities omits a capability its source uses (e.g. capabilities: [] + ctx.api.object('x').count({})), invoke it over REST.

Why

The capability gate throws SandboxError synchronously inside a QuickJS host function (packages/runtime/src/sandbox/quickjs-runner.ts — capability '${required}' not granted…). That throw rejects the async IIFE inside the VM, so it surfaces through the __error side-channel, and the pump loop wraps it as:

throw new SandboxError(
  `${kind} '${name}' threw: ${errStr}`,
  userFacingMessage(String(errStr)),   // ← innerMessage SET
  readErrorInfo(vm),
);

i.e. innerMessage is set for a host-side denial, because anything that comes back through __error is presumed "user code threw this deliberately". The dispatcher's classifier (packages/runtime/src/domains/actions.ts, unexpectedFault) then reads innerMessage-present as a deliberate rejection → 400. The contract's "capability denial has no user-meaningful inner message" (SandboxError jsdoc) only holds for denials detected outside evaluation.

Timeouts take the separate budgetError path, so the 500 half of the contract likely still holds for them; it is the in-VM host-call denials (capability gates on ctx.api.*, ctx.log, ctx.crypto) that get misclassified.

Expected

A capability denial (and any other sandbox-internal fault crossing __error) reaches the classifier distinguishable from a user throw — e.g. the host-function throw carries a marker (code: 'CAPABILITY_DENIED' / a sentinel the pump loop recognizes before setting innerMessage) — and answers 500 through errorFromThrown, without the SandboxError: prefix reaching the client.

Part of the #3909 rc.1 verification (section F1).

Activity

  1. baozhoutao commented on Aug 1, 2026

    @baozhoutao
    ContributorAuthor

    Tracked under the v17 verification tracker #3909 (section F, rc.1 run 1).

  2. baozhoutao commented on Aug 1, 2026

    @baozhoutao
    ContributorAuthor

    Rolled up in #4482 (all 17 defects from the v17 verification, grouped by severity with a suggested RC-exit triage).

  3. self-assigned this
    on Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions