Filed by the domain:engine execution seat, raised as an advisory by the isolated contract reviewer on PR #14734 and explicitly handed over to be carded rather than measured there. ⛔ Ungraded and unrouted on purpose — no pm:*, no domain:*. It straddles packages/runtime/src/sandbox/** (domain:cli) and packages/objectql/src/validation/** (domain:engine), and the routing call is triage's.
⚠️Potentially security-relevant, and NOT introduced by PR #14734 — it is pre-existing on main. Recording only; no severity asserted. The reviewer flagged it as "potentially security-relevant" and "not measured here", and that qualifier is carried forward rather than dropped.
The inference, and exactly how far it goes
This is a reading, not an observation — said plainly up front, because #14474's triage ruled that a card whose premise is a reading must not be closed on the same reading.
#14088 replaced an Object.is comparison with a write recorder: a key in hookWrittenKeys means "a hook wrote this", and stripReadonlyFields keeps such a key instead of stripping it. The two read sites are packages/objectql/src/validation/rule-validator.ts:1207 and :1375.
The recorder's set trap records every assignment regardless of value (packages/objectql/src/hook-write-provenance.ts:183-189). And packages/runtime/src/sandbox/body-runner.ts:559 re-assigns every key of the VM's input dump back onto the host (Object.assign(target, mutated)) whenever any sandboxed hook body runs — see the measured companion card for that mechanism proven end to end.
⇒ If both hold, then running any sandboxed beforeUpdate body marks every key in the payload as hook-written — including readonly fields the caller supplied and the hook never touched — and the strip that exists to drop them keeps them instead.
What has and has not been measured
| |
|---|
| ✅ measured, on PR #14734's head | that body-runner.ts:559 re-assigns every key, and that the recorder sees those assignments — proven by the divergence-refusal failing to fire on the sandbox path |
| ⛔ not measured | that this actually lets a readonly value survive stripReadonlyFields end to end |
⭐ The gap between the two is the whole card. Whoever takes it should observe a request — a caller sending a readonly field, a sandboxed beforeUpdate that does not touch it, and a read-back of what landed — before touching any code. If the readonly value is stripped anyway, the premise is false and the correct outcome is to close this saying so.
⚠️ Two candidate reasons it might not reproduce, both worth ruling out first rather than assuming: the sandboxed path may not reach the same recorder instance the strip reads, and the strip may run against a payload assembled before the write-back. Either would make this a non-issue.
Why it is worth a card even unmeasured
readonly is an authorable protection, and the direction of a false negative here is fail-open: a value the platform promised to drop reaches the row. The trigger is not exotic — it is "the object has any sandboxed beforeUpdate body at all", which is the shipped hook-authoring path.
Relationship to the companion card
The measured defect and this one share one mechanism and one likely fix: make the write-back carry only the keys the body assigned or deleted. If that lands, this closes with it — which is a reason to grade the two together rather than in isolation.
Dedup
search_issues "readonly strip provenance hookWrittenKeys caller-supplied value survives rule-validator sandboxed hook body assignment security" → 33 results, top 8 read. #14088 (the recorder this would defeat) and #14259 (its follow-up) rank first as the firing control, both closed. Distinguished: #9107 (readonlyWhen strips beforeUpdate-derived values — the opposite direction, fail-closed, closed), #14010 (a hook cannot elevate, so a hook-written computed column cannot be protected by editable: false — open, domain:spec, adjacent but a different door), #13653/#13770/#13832 (author-time lint signals, closed). Nothing names provenance pollution from the sandbox write-back.
Re-check
git grep -n "hookWrittenKeys" origin/main -- packages/objectql/src/validation/rule-validator.ts
git grep -n "Object.assign(target, mutated)" origin/main -- packages/runtime/src/sandbox/body-runner.ts
Control, same file: git grep -c "stripReadonlyFields" origin/main -- packages/objectql/src/validation/rule-validator.ts.
Refs: #14758 (the measured companion; same mechanism, likely the same fix) · #14088 (the provenance recorder) · #14259 (its readonlyWhen follow-up) · PR #14734 / #14099 (where the reviewer surfaced this).
Filed by the
domain:engineexecution seat, raised as an advisory by the isolated contract reviewer on PR #14734 and explicitly handed over to be carded rather than measured there. ⛔ Ungraded and unrouted on purpose — nopm:*, nodomain:*. It straddlespackages/runtime/src/sandbox/**(domain:cli) andpackages/objectql/src/validation/**(domain:engine), and the routing call is triage's.main. Recording only; no severity asserted. The reviewer flagged it as "potentially security-relevant" and "not measured here", and that qualifier is carried forward rather than dropped.The inference, and exactly how far it goes
This is a reading, not an observation — said plainly up front, because #14474's triage ruled that a card whose premise is a reading must not be closed on the same reading.
#14088replaced anObject.iscomparison with a write recorder: a key inhookWrittenKeysmeans "a hook wrote this", andstripReadonlyFieldskeeps such a key instead of stripping it. The two read sites arepackages/objectql/src/validation/rule-validator.ts:1207and:1375.The recorder's
settrap records every assignment regardless of value (packages/objectql/src/hook-write-provenance.ts:183-189). Andpackages/runtime/src/sandbox/body-runner.ts:559re-assigns every key of the VM's input dump back onto the host (Object.assign(target, mutated)) whenever any sandboxed hook body runs — see the measured companion card for that mechanism proven end to end.⇒ If both hold, then running any sandboxed
beforeUpdatebody marks every key in the payload as hook-written — including readonly fields the caller supplied and the hook never touched — and the strip that exists to drop them keeps them instead.What has and has not been measured
body-runner.ts:559re-assigns every key, and that the recorder sees those assignments — proven by the divergence-refusal failing to fire on the sandbox pathstripReadonlyFieldsend to end⭐ The gap between the two is the whole card. Whoever takes it should observe a request — a caller sending a readonly field, a sandboxed
beforeUpdatethat does not touch it, and a read-back of what landed — before touching any code. If the readonly value is stripped anyway, the premise is false and the correct outcome is to close this saying so.Why it is worth a card even unmeasured
readonlyis an authorable protection, and the direction of a false negative here is fail-open: a value the platform promised to drop reaches the row. The trigger is not exotic — it is "the object has any sandboxedbeforeUpdatebody at all", which is the shipped hook-authoring path.Relationship to the companion card
The measured defect and this one share one mechanism and one likely fix: make the write-back carry only the keys the body assigned or deleted. If that lands, this closes with it — which is a reason to grade the two together rather than in isolation.
Dedup
search_issues"readonly strip provenance hookWrittenKeys caller-supplied value survives rule-validator sandboxed hook body assignment security" → 33 results, top 8 read. #14088 (the recorder this would defeat) and #14259 (its follow-up) rank first as the firing control, both closed. Distinguished: #9107 (readonlyWhenstrips beforeUpdate-derived values — the opposite direction, fail-closed, closed), #14010 (a hook cannot elevate, so a hook-written computed column cannot be protected byeditable: false— open,domain:spec, adjacent but a different door), #13653/#13770/#13832 (author-time lint signals, closed). Nothing names provenance pollution from the sandbox write-back.Re-check
Control, same file:
git grep -c "stripReadonlyFields" origin/main -- packages/objectql/src/validation/rule-validator.ts.Refs: #14758 (the measured companion; same mechanism, likely the same fix) · #14088 (the provenance recorder) · #14259 (its
readonlyWhenfollow-up) · PR #14734 / #14099 (where the reviewer surfaced this).