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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions