Skip to content

The sandbox hook write-back re-assigns EVERY ctx.input key, not the ones the body wrote — so #14099's per-row divergence refusal is order-dependent for shipped hook bodies and the corruption still lands #14758

Description

@os-musk

Filed by the domain:engine execution seat on behalf of an isolated contract reviewer, which measured this on PR #14734's head (a59f92f37) and returned FAIL on that PR because of it. ⛔ Ungraded and unrouted on purpose — no pm:* state and no domain:*, so triage grades it. It lands in packages/runtime/src/sandbox/**, which the lane table puts in domain:cli; the engine seat does not own that package.

Filed with the reproduction already done, so grading does not need a dispatch first.

What was measured

The reviewer built a throwaway probe — real ObjectQL + real SqlDriver/better-sqlite3 + real QuickJSScriptRunner behind hookBodyRunnerFactory, with the AppPlugin wiring copied from packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts — and drove #14099's exact fixture through the transition-stamp hook: one open row, one already-done row, { status: 'done' }, multi: true, in both dispatch orders, as a QuickJS body and as an in-process handler.

hook kind dispatch order refused? already.completed_at
in-process open → already yes — MULTI_UPDATE_HOOK_KEY_DIVERGENCE / 400, keys ['completed_at'] unchanged
in-process already → open yes unchanged
sandbox open → already NO ⛔ moved to the stamp
sandbox already → open yes unchanged

⇒ On the shipped hook-body path, in one of two row orders, PR #14734's refusal does not fire and #14099's original corruption still lands.

The mechanism, named to the line

  1. packages/runtime/src/sandbox/quickjs-runner.ts:1302-1319 (readCtxInputJson) dumps the VM's entire ctx.input, not the keys the body touched.
  2. packages/runtime/src/sandbox/body-runner.ts:543-560 (applyMutationsToInput) re-assigns every key of that dump back onto the host — Object.assign(target, mutated) at :559.
  3. That write goes through the flat-input proxy at packages/objectql/src/hook-wrappers.ts:605-614 (ensureData()[prop] = value).
  4. The stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder's set trap records every assignment regardless of value — packages/objectql/src/hook-write-provenance.ts:183-189.

On the shared D3 payload, a non-transitioning row dispatched after a transitioning one therefore re-writes the inherited completed_at. Both observation windows then contain the same key, divergingHookPayloadKeys sees no divergence, and the refusal abstains.

⚠️ From the hook author's seat the outcome depends on the driver's row order — precisely the "failure direction nobody can debug" that PR #14734's own module docblock warns against at multi-update-hook-key-divergence.ts:49.

Why PR #14734's verification could not have caught it

⚠️ The named consumers are on the uncovered path. hotcrm ships hook bodies (the #11552 harness docblock cites hotcrm's shipped body), and PR #14734's changeset writes its route 1 in sandbox-signal terms.

Suggested direction (not a decision)

Carry back only the keys the body actually assigned or deleted, rather than the whole input dump. ⭐ The in-tree pattern already exists one file over: the ctx.record write-recorder at quickjs-runner.ts:1321+. Pin it with the probe's shape — real QuickJS, real driver, both row orders refused, and a sandboxed row-invariant hook correctly not refused.

⚠️ Note the second-order effect before choosing: delete ctx.input.<k> in a sandboxed body is already a silent no-op (#12277 — the flat-input proxy traps get/set/has/ownKeys but not deleteProperty, closed). A key-set write-back has to decide what a deletion means on that path rather than inherit the ambiguity.

Dedup

search_issues "sandbox hook body write-back re-assigns every ctx.input key Object.assign applyMutationsToInput pollutes hookWrittenKeys per-row divergence undetected QuickJS runner" → 35 results, top 8 read. #14099 and #14744 rank first, which is the firing control. Distinguished: #12277 (same proxy, the missing deleteProperty trap — different defect, closed), #7254 (the sandbox input.data spelling, closed), #11552 (a body-only hook can reach none of D3's three routes — the adjacent territory this sits in, closed). Nothing names the over-broad write-back.

Re-check

git grep -n "Object.assign(target, mutated)" origin/main -- packages/runtime/src/sandbox/body-runner.ts
git grep -n "readCtxInputJson" origin/main -- packages/runtime/src/sandbox/quickjs-runner.ts

Control, same files: git grep -c "ctx.input" origin/main -- packages/runtime/src/sandbox/body-runner.ts.

Sequencing

PR #14734 is held draft and #14099 is being marked blocked on this card. Its engine-side refusal is correct as far as it reaches; it simply cannot be true end-to-end until the write-back reports honestly. ⛔ The engine seat did not widen that PR into packages/runtime/src/sandbox/** — another lane's package — and did not narrow the ruled prescription to in-process handlers on its own authority.

Refs: #14099 / PR #14734 (the refusal this defeats) · #14088 (the provenance recorder whose set trap is being fed noise) · #11552, #12277, #7254 (adjacent sandbox-path cards) · #14744 (the residue #14099 deliberately left open — different defect).

Activity

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:clipriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions