refactor(#591): fail-closed decoders for persisted domain records (phase 3) - #601
Conversation
Phase 3 of the #593 refactor umbrella. The five remaining raw `as`-cast persisted-domain reads in `createState` (varValues, filterActive, varRecent, varRecentDisabled, history) now decode through five new pure functions in core/state-codec.ts, the same decodeStoredSavedQueries/decodeSidePanelKey precedent #587/#586 already established: a malformed top level fails closed to the field's documented default (fresh each call, never shared), and a well-formed top level with malformed individual entries drops only those entries. HistoryEntry moves from state.ts to core/state-codec.ts (re-exported so existing importers keep compiling), and its new HISTORY_MAX_ENTRIES constant replaces the literal 50 at both decode time and pushHistory's write-side cap so the two can't drift. Also fixes an inherited #586 finding: firstValidPx used bare parseInt + Number.isFinite, which silently accepted a non-numeric tail ('420px', '1e3') or read only a leading digit ('0x10' -> 0) as valid. It now requires a complete, optionally-signed decimal integer before parsing, so a lenient-but-malformed canonical value correctly falls through to a real legacy fallback instead of a wrong number. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
Medium-risk units previously skipped plan review by default, relying on solo planning to catch architecture gaps. Owner requested chatgpt-review run by default for Medium risk (same escape hatch High already has for ChatGPT unavailability), based on this phase's plan turning out sound but having gone unreviewed before implementation started. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
BorisTyshkevich
left a comment
There was a problem hiding this comment.
ChatGPT review pass 1
Reviewed head: 509ca2024511ace552562523daf4a3d8cb971c96
Major findings
-
decodeStoredRecentMapis prototype-sensitive for a stored"__proto__"variable name.- File:
src/core/state-codec.ts:65-69 - The decoder rebuilds
byNameas{}and writes each untrusted persisted name withbyName[name] = filtered. Forname === "__proto__", that invokesObject.prototype.__proto__instead of creating an own map entry. A value parsed from{"byName":{"__proto__":[{"value":"x","seq":1}]}}therefore returns abyNamewhose prototype is the attacker-controlled array, with no own__proto__property.for...inconsumers then see inherited array indices, so the decoder has surfaced a wrongly-shaped map rather than failing closed. - Fix: create the property with
defineJsonField,Object.defineProperty, or anObject.fromEntriesrebuild; do not use bracket assignment on{}for persisted keys. Add a regression test assertingObject.getPrototypeOf(result.byName) === Object.prototype,Object.hasOwn(result.byName, "__proto__"), and that the entry round-trips. The same keyed-write pattern inenforceTotalCapshould be made safe so a correctly decoded own key is not re-corrupted later.
- File:
-
firstValidPxaccepts syntactically valid decimal strings whose conversion is not finite, so a corrupt canonical value still beats valid legacy fallbacks.- File:
src/state.ts:655-659 - The regex rejects junk but the code immediately returns
parseInt(t, 10)without restoring the previousNumber.isFinitecheck. For example,"9".repeat(400)matches the regex and parses toInfinity;"-" + "9".repeat(400)parses to-Infinity. With a validdocPanePxof420, the former leavesstate.rightInspectorPx === Infinity, while the latter is clamped to320; neither falls through to420. This violates the inherited requirement to select the first fully-valid finite candidate and the documented valid-candidate precedence. - Fix: parse after the full-string match, then return only when
Number.isFinite(n); otherwise continue to the next candidate. Add overflow regressions for a corrupt canonical value followed by a validdocPanePx, and for corrupt canonical +docPanePxfollowed by a validcellDrawerPx.
- File:
Verification
The canonical PR still pointed at the SHA above when this review was submitted. GitHub's ci workflow is green for that head. Direct git clone was unavailable in the review runtime, so I inspected the complete six-file canonical diff and surrounding files through GitHub and ran focused Node reproductions for both cases; both reproduce as described.
…-closed decoders ChatGPT review of PR #601 (phase 3 of #593) found two Major defects in the new fail-closed decoders: - decodeStoredRecentMap built byName via a bare `byName[name] = filtered` bracket assignment. A persisted name of "__proto__" hits Object.prototype's inherited __proto__ setter instead of creating an own property: the entry silently vanishes from Object.keys/normal enumeration and the returned object's own prototype chain gets swapped to the array, corrupting hasOwnProperty/enumeration behavior for the whole map. Now built via Object.fromEntries (DefineOwnProperty semantics), matching the decodeStoredVarValues/decodeStoredFilterActive precedent already in this file — a "__proto__" name survives as an ordinary own property. - firstValidPx (state.ts) validated a candidate with a regex confirming a complete decimal-digit string, then trusted parseInt's result without checking it was finite. parseInt accumulates in floating point, so a long all-digit string (or its negative form) overflows to Infinity/-Infinity, which then reaches `clamp(x, 320, Infinity)` unclamped — producing a literal Infinity pixel width and wrongly beating a valid legacy fallback since the overflowing candidate is checked first. Now rejects any candidate whose parseInt result isn't finite, falling through to the next candidate or to 480. Both fixes verified via sabotage: reverting to the prior imperative byName[name]= loop and the bare parseInt (no Number.isFinite check) confirmed the corresponding new tests fail, before restoring the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
BorisTyshkevich
left a comment
There was a problem hiding this comment.
ChatGPT review pass 2
Previously reviewed head: 509ca2024511ace552562523daf4a3d8cb971c96
Reviewed head: 8cd85afd46edf13fa94c952cd571c682cc10197d
Reassessment of pass-1 findings
firstValidPxoverflow: resolved. The new post-parseIntNumber.isFinitecheck rejects both positive and negative overflow and preserves valid fallback precedence.- Direct
decodeStoredRecentMapprototype mutation: resolved at the decoder site.Object.fromEntriespreserves a stored"__proto__"name as an ordinary own property without changing the result object's prototype.
Major finding
- The pass-1 downstream warning is still unfixed:
enforceTotalCapre-corrupts the safely decoded"__proto__"entry on the first over-cap recording.- File:
src/core/recent-values.ts:77-81 - Pass 1 explicitly called out the equivalent keyed write in
enforceTotalCap. The updated decoder now correctly returns a normal object with an own enumerable"__proto__"entry, butrecordRecentcallsenforceTotalCapwhenever the total grows past 100. That function rebuilds into{}and still executesbyName[name] = list. When the surviving name is"__proto__", the assignment invokes the inherited setter, changes the rebuilt map's prototype to the entry array, drops the own property, and the next persistence write loses that entry. - Focused reproduction: start with a safely decoded map containing
"__proto__"plus 99 one-entry names (100 total), give"__proto__"a high sequence so it should survive eviction, then record one new name. Before recording, the key is own and the prototype isObject.prototype; afterwards, the key is no longer own, the prototype is an array, andJSON.stringify/parse no longer contains"__proto__". - Fix: rebuild the capped map with
Object.fromEntries,defineJsonField, orObject.defineProperty, not bracket assignment onto{}. Add an end-to-end regression that runsdecodeStoredRecentMap(...)followed byrecordRecent(...)at the 100→101 boundary and asserts the normal prototype, own"__proto__"property, retained value, and persistence round-trip.
- File:
Verification
The new head is exactly one commit ahead of the pass-1 SHA and changes only src/core/state-codec.ts, src/state.ts, and their two unit-test files. I re-inspected the complete updated six-file PR and relevant recent-values.ts path. GitHub's ci workflow is green for the reviewed head. Direct cloning remains unavailable in this runtime due DNS resolution, so focused Node reproductions were used for the repaired sites and the remaining failure.
…ts's total-cap eviction ChatGPT's pass-2 review of PR #601 found the exact bracket-assignment prototype-mutation bug this PR already fixed in decodeStoredRecentMap (state-codec.ts) still present at the write side, in enforceTotalCap (recent-values.ts): `byName[name] = list` on a plain-object literal hits Object.prototype's inherited __proto__ setter instead of creating an own property when name === "__proto__", silently dropping the entry from enumeration and swapping the object's actual prototype to the array. This is reachable in normal usage — name is a live SQL variable name a user can type directly (e.g. a `{__proto__:String}` query parameter) — not only via corrupted localStorage. Rebuilt via Object.fromEntries, same as decodeStoredRecentMap. Fixing this surfaced two more instances of the same "__proto__" hazard in this file, on the read side, which a plain-object bracket lookup or `in` check also has to guard against: - recordRecent's `m.byName[name] || []`: a plain object with no own "__proto__" property still answers a bracket read of "__proto__" via the inherited accessor getter, returning the object's own prototype (a truthy object) instead of undefined — `|| []` never fires and the subsequent `.filter` throws on a non-array. Same for visibleRecents' identical pattern. - clearRecent's `name in m.byName`: `in` walks the prototype chain, so `"__proto__" in {}` is true even with zero recorded history, breaking the documented same-reference no-op contract for that one name. Both now guarded with Object.hasOwn, matching this codebase's existing guard pattern (saved-query.ts, dashboard-variables.ts, spec-schema.ts). recordRecent's own `{ ...m.byName, [name]: list }` write was checked and confirmed already safe — an object-literal computed property uses CreateDataPropertyOrThrow, not the inherited setter — and is now commented as such so the distinction isn't re-litigated later. Added a test recording enough distinct names to trigger the 100-entry total-cap eviction with a "__proto__"-named entry surviving it, asserting byName's prototype is still Object.prototype, "__proto__" is a normal own key, and its value round-trips correctly. Verified via sabotage: reverting enforceTotalCap and the two read-side guards back to the original bracket forms reproduces the exact `existing.filter is not a function` crash and fails the new test; restoring the fix passes again. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
BorisTyshkevich
left a comment
There was a problem hiding this comment.
ChatGPT review pass 3
Previously reviewed head: 8cd85afd46edf13fa94c952cd571c682cc10197d
Reviewed head: 812eaa43b3721d59a6aedef1fa1c898f22f3e4f2
Reassessment of earlier findings
firstValidPxaccepts overflowing integer strings: resolved. The full-string validation is followed by a finite-result check, so positive and negative overflow no longer beat valid legacy fallbacks.decodeStoredRecentMapmutates the rebuilt map prototype for a stored"__proto__"key: resolved. The decoder usesObject.fromEntries, preserving the key as an own enumerable data property on a normal object.enforceTotalCapre-corrupts that key during the 100→101 eviction path: resolved in this head. The cap rebuild now also usesObject.fromEntries.- Related inherited-key read behavior: the new
Object.hasOwnchecks inrecordRecent,clearRecent, andvisibleRecentscorrectly prevent inherited__proto__/prototype-member reads from being treated as recorded variable histories.
Findings
No actionable findings in this pass.
The new head is exactly one commit ahead of the pass-2 SHA and changes only src/core/recent-values.ts and tests/unit/recent-values.test.ts. I re-inspected the complete updated eight-file PR and the affected persistence/mutation path. A focused reproduction of the exact pass-2 scenario—an own "__proto__" entry at the 100-entry cap followed by recording another name—kept Object.prototype, retained the own entry, evicted the true lowest-sequence entry, and preserved "__proto__" through JSON serialization and parsing. The empty-map recordRecent, clearRecent, and read behavior for that name also behaved as intended.
GitHub's ci workflow is green for the reviewed head. Direct repository cloning remains unavailable in this runtime, so verification used the canonical GitHub diff/files and focused Node reproductions.
…yout and tile-open-workbench specs Two e2e specs raced an async settle with no wait, both surfaced by Altinity#587's "surface out-of-scope findings" rule and reproduced again on CI for Altinity#601. - inspector-dock-layout.spec.js:155 read `.inspector-host`'s boundingBox() immediately after `page.setViewportSize` — but the displayed width is recomputed by app-shell.ts's `reclampInspectorWidth`, a real `window` 'resize' event LISTENER dispatched asynchronously relative to `setViewportSize`'s own resolution. Under `--repeat-each=15 --workers=12` this reproduced the exact CI signature ("Expected 320, Received 500"); wrapping the read in `expect.poll` closes it (confirmed: 0 failures across the same stress level, run twice, after the fix). - tile-open-workbench.spec.js:364 read the committed `window.__dashboard()` state immediately after `widen.click()` — but the widen press applies OPTIMISTICALLY first (`runCommand`, src/ui/dashboard.ts) while the actual persisted commit is a separate, fire-and-forget `app.mutateWorkspace` call. The geometry assertions right above it are safe (same optimistic doc, no gap); only the persisted-state read raced. Same `expect.poll` fix, mirroring the pattern the file's own later "narrow tile" test already uses for the identical read. Audited both files in full for the same shape (state/viewport change immediately followed by a bare geometry or persisted-state read); every other instance is either already polled or gated behind a prior polling assertion whose pass already implies the read is safe, so no other site needed a change. No production code touched — the optimistic-apply-then-commit behavior powering both races is working as designed. Verified: full gate green (types/arch/schemas/examples/unit/build); both specs run 8x in isolation with 0 failures; the fixed assertions stress-tested at `--repeat-each=15 --workers=12` (chromium+webkit) with 0 failures across multiple rounds; full parallel suite (`--project=chromium --project=webkit`) run 3x, 414 passed/4 skipped every time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
What & why
Part of #593 (phase 3 of 8). Implements #591: gives the five persisted-domain reads in
src/state.tsthat previously trustedlocalStorageverbatim (varValues,filterActive,varRecent,varRecentDisabled,history) a fail-closed decoder each,following the pattern
decodeStoredSavedQueriesalready establishes incore/library-codec.ts— drop malformed data to the field's documented default, neverthrow, never let a corrupt or hand-edited value reach the UI with the wrong shape.
Also fixes an item inherited from #586 (phase 1):
firstValidPx's validator wasparseInt-lenient ('1e3'→1,'0x10'→0,'420px'→420), so a genuinelycorrupt canonical
rightInspectorPxcould still beat a valid legacy fallback. It nowrequires a complete, trimmed, optionally-signed decimal integer before accepting a
candidate, preserving the documented
rightInspectorPx → docPanePx → cellDrawerPx → 480precedence among fully-valid candidates only.
This PR also updates this repo's
/shipskill (skills/ship/references/per-issue-cycle.md):Medium-risk plan review now defaults to a
chatgpt-reviewplan-mode pass (previously"none by default"), matching what this phase's plan should have gotten before
implementation — the plan turned out sound, but going forward Medium-risk plans get the
same second opinion High-risk plans already do, with the same escape hatch if ChatGPT is
unreachable. Bundled into this PR at the owner's explicit request.
Contract coverage
All 4 acceptance criteria + both inherited-item checkboxes claimed and met, nothing
deferred:
src/core/state-codec.ts), not araw
ascast.{}/{}/emptyRecentMap()/false/[]) on malformed input and never throws.varRecent's per-namelists,
history) are dropped without discarding the rest of the collection.npm test(coverage gate),tsc --noEmit,check:arch,check:schemas,check:examples, andnpm run buildall pass.firstValidPxrejects trailing junk, exponent form, hex form, and whitespace-onlyvalues instead of coercing them.
position (
docPanePx,cellDrawerPx, and the all-corrupt→480 case).Non-goals respected: no change to the four numeric geometry preferences'
clampNaN-safety (that was #570's scope, closed Not Planned); no behavior change for
well-formed persisted data (verified — the existing
'uses defaults'and'reads + clamps persisted prefs'tests pass unmodified);decodeStoredSavedQueriesitselfuntouched.
Invariant verification
unknowncreateStatereads all five fields through decoders, not raw castshistory's decode cap andpushHistory's write cap can't drift apartHISTORY_MAX_ENTRIES.slice()→ cap test failedfirstValidPxaccepts only a complete trimmed optionally-signed decimal integerparseIntparseInt+Number.isFinite→ lenient-form regression tests failedfirstValidPxpreserves documented precedence among valid candidates only!!instead of requiring the exact type → entry-filter test failedAll 8 sabotage cases were executed by the implementation worker, each reverted, and the
full gate re-confirmed green. The coordinator independently re-verified the wiring
invariant with its own sabotage/revert cycle (see below) rather than trusting the
worker's self-report.
Tests
tests/unit/state-codec.test.ts(new, 19 tests) — onedescribeper decoder,covering wrong-top-level-type, entry-level drop-and-keep, fresh-fallback, and
(for
history) the size cap.tests/unit/state.test.ts(+11 tests) — 5 wiring tests provingcreateStateactuallycalls each decoder end-to-end, plus 6
firstValidPxregression cases for theinherited item.
'uses defaults'and'reads + clamps persisted prefs'tests passunmodified, proving no behavior change for well-formed data.
Build & gate
check:types,check:arch(no boundary violations),check:schemas,check:examples,npm test(207 files / 6810+ tests, 0 coverage-threshold errors —state-codec.ts100/100/100/100 confirmed via lcov,
state.ts100/96.23/100/100, both clear the100/95/90/100 floor),
npm run build— all run explicitly and independentlyre-verified by the coordinator (
.npmrcsetsignore-scripts=truehere, so a greennpm testalone would not provetsc/arch/schema checks ran).e2e
npx playwright test --project=chromium --project=webkit: 418 tests, 414 passed, 4skipped (touch-only, feature-gated), 0 failed. This phase has no UI-visible surface, but
state.tsis widely depended upon so the full e2e suite was run as a regression check.Checklist
npm testpasses (the per-file coverage gate is non-negotiable)npm run buildsucceeds (single-filedist/sql.html)src/core/, network insrc/net/(injectedfetch), DOM in
src/ui/CHANGELOG.md([Unreleased]) updated## Phaseschecklist and ship-logcomment will be updated once this PR is open (phase 3 row); no other tracked work
is reshaped by this change
🤖 Generated with Claude Code
https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF