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 SandboxErrorsynchronously inside a QuickJS host function (packages/runtime/src/sandbox/quickjs-runner.tscapability '${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:

thrownewSandboxError(`${kind} '${name}' threw: ${errStr}`,userFacingMessage(String(errStr)),// ← innerMessage SETreadErrorInfo(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).

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