Skip to content

Round-6 review fixes: untrusted-diagram attack surface, multi-tab data loss, animation bounds - #66

Merged
zntznt merged 4 commits into
mainfrom
claude/ui-ux-run-4qmy5r
Aug 31, 2026
Merged

zntznt merged 4 commits into
mainfrom
claude/ui-ux-run-4qmy5r

Conversation

@zntznt

@zntznt zntznt commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Fixes all fourteen findings from the sixth multi-agent review, plus the formula code-execution sink I confirmed before launching it.

Each fix has a test that I verified fails against the unpatched code first. 258 unit tests, smoke green.

Security: a diagram is untrusted input

The product invites exactly this. It advertises "Copy share link" (a base64 diagram in the #d= hash), File > Open of .json and .econ, library components, and node cli.js someones-economy.econ. Five ways a diagram could act on the reader:

Sink Baseline Fixed
evalFormula fell through to new Function(expr) opening a share link executed the author's JavaScript with no click; the CLI ran it with the invoking user's privileges payload evaluates to 0, validateFormula reports it invalid
Parameter names interpolated into the sweep table with innerHTML 3 injected <img> elements, script running in the app's origin with read access to sim_library and sim_autosave rendered as text
meta.name interpolated into the generated module's banner comment a name containing the comment terminator ran code on require() contained
meta.font pasted into a fonts.googleapis.com URL opening a shared diagram phoned home with a sender-chosen string, leaking the reader's IP off-list families ignored, 0 requests
Share link adopted into autosave with no prompt the reader's saved diagram silently replaced, nothing to undo asks first, restores theirs on decline

Removing the formula fallback needed evidence it was safe. All 110 distinct expressions across the 12 demos and 11 templates compile under math.js. The JS-flavoured spellings a saved diagram might use still work: a seeded Math object is exposed in scope, and an expression math.js refuses is retried with the operators rewritten (&&→and, ||→or, !→not, **→^, ===→==). One deliberate semantic change: a && b used to return b's value under JS truthiness and now returns 1 or 0, which is the answer math.js gives everywhere else in these numeric fields.

The meta.font leak also contradicted the no-Google-Fonts-at-runtime claim in CLAUDE.md and README.md.

Data loss

  • Library Delete, Duplicate and Rename wrote back the array the list was rendered from. Measured through the real Delete menu item: a library holding one entry from each of two tabs ended up empty. Now they re-read storage and locate the target by a stable per-entry id.
  • sim_autosave is one slot shared by every tab. Nothing can merge two diagrams, but the superseded tab is now told once, while its work is still on screen and exportable.
  • The +/- steppers changed the canvas, undo and autosave but not the reset baseline, so Reset or a reload silently reverted them. The autosave recorded the contradiction directly: resources: 13 next to initialResources: 10.

Determinism and animation

  • Repainting consumed the seeded RNG. A gate's weight label mirrors the engine and evaluates the formula, and the canvas repaints on hover, pan, zoom, selection and every step, so the same diagram under the same seed gave different results depending on mouse movement. Measured: a single render() moved the stream position.
  • The animation layer had no ceiling and no idea when it was off screen. Cleanup runs from requestAnimationFrame, which browsers suspend in a hidden tab while the setInterval driving steps keeps firing. Measured: 838 live dots in the foreground on a heavy model at top speed, growing to 2431 while hidden. Now capped at 240, skipped entirely while document.hidden, and dropped on visibilitychange.

Panels that lied

  • Hiding the overflow menu in embeds last round left a narrow embed with no way to reset a run at all. Reset comes back at those widths.
  • Loading a template left the Simulation panel showing the empty canvas it was drawn against: blank Name, 0 nodes, beside a canvas full of them.
  • The (node deleted) placeholder added last round was selectable, and picking it restored the exact misleading state it exists to prevent. It is disabled now.

Food Web: description corrected, mechanics untouched

The template claimed "Ten species lock into coupled, bounded oscillations", but Foxes crash to 1 and stay there with foxBirths at 0. This is not a mistuned constant: at three times the birth rate and a quarter of the death rate they still peak at 167 and collapse to 1, because their break-even prey population sits near the rabbit peak and they eat the base out from under themselves.

Retuning the predator-prey coupling is a design decision about the demo rather than a defect fix, and the surrounding web is sensitive to it: pushing fox efficiency collapsed the rabbit oscillation from a span of 93 to 17. So the mechanics are unchanged and the description now says what the model does.

Testing

  • node test/run.js — 258 passed (was 255 on main)
  • npm run smoke — passed, no console or page errors
  • Every new test baselined against the unpatched code: each fails without its fix
  • New standing check: every bundled template runs 120 steps with every value finite. Smoke previously only asserted that they load.

Two caveats

  • The document.hidden guard is not provable in this harness. Headless Chromium reports a backgrounded page as visible, so the test covers what is verifiable: the 240 cap, and the visibility handler driven through a real visibilitychange event.
  • The share-link prompt is a design call. Two review lanes reached opposite verdicts on it, one arguing the behaviour is a deliberate documented boundary. I dropped the part of the complaint about Ctrl+Z, since no page load can offer undo for pre-load state, but kept the prompt: destroying a reader's saved work without asking is a bad outcome even when it was a considered decision. Straightforward to revert.

Generated by Claude Code

claude added 4 commits August 31, 2026 15:10
Round-6 findings 1, 3 and 4, plus the formula code-execution sink I confirmed
before the review ran and the display-font beacon it flagged as a lead.

A diagram is untrusted input: it arrives as a shared #d= link, a downloaded
.json or .econ, a library component, or a cli.js argument. Four ways it could
act on the reader.

1. evalFormula fell through to new Function(expr) whenever math.js could not
   parse an expression, and validateFormula reported such a payload as valid so
   nothing warned. reset() evaluates register formulas on load, so opening a
   share link ran the author's JavaScript with no click at all, and running
   "node cli.js someones-economy.econ" ran it with the invoking user's
   privileges. The fallback is gone. Nothing needs it: all 110 distinct
   expressions across the 12 demos and 11 templates compile under math.js, and
   the JS-flavoured spellings a saved diagram might use are handled on the
   math.js path instead, by exposing a seeded Math object in scope and by
   retrying an expression math.js has already refused with the operators
   rewritten (&& to and, || to or, ! to not, ** to ^, === to ==).
   One deliberate semantic change: "a && b" used to return b's value under JS
   truthiness and now returns 1 or 0. These are numeric fields, so a boolean is
   the more useful answer, and it is the same answer math.js gives everywhere
   else.

2. Parameter names were interpolated into the sweep results table with
   innerHTML and no escaping, so a parameter named with an img onerror ran the
   author's script in the app's origin the moment the reader pressed Run sweep,
   with read access to sim_library and sim_autosave. Every other interpolation
   in that function already escaped; this one did not. Checked the rest of the
   app's innerHTML sinks: this was the only unescaped one carrying diagram text.

3. meta.name was interpolated into the header banner of the generated module,
   so a name containing the comment terminator ended it early and the rest
   became top-level code that ran on require(). The banner escapes it now, and
   the module's global name is held to an identifier.

4. meta.font was pasted into a fonts.googleapis.com stylesheet URL, so opening
   a shared diagram fired a request to a third party carrying a string of the
   sender's choosing, telling them the reader's IP and that they had opened it.
   It also contradicted the no-Google-Fonts-at-runtime claim in CLAUDE.md. Only
   a family from the curated list is honoured now.

Separately, opening a share link replaced the reader's autosaved diagram with
no prompt, and a page load has no undo stack for state from before it, so the
work was simply gone. The link is still adopted (a reload has to restore what
is on screen), but it asks first when there is real work in the saved slot, and
puts the reader's diagram back if they decline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
…ted-node placeholder

Round-6 findings 6 and 7. Finding 6 is a regression from the previous round.

At step 0 the amount field is labelled Starting amount, but the +/- buttons
beside it went through addResources/takeResources, which touch only the live
count; setCount is what writes the reset baseline. So three clicks on "+"
changed the canvas, the properties readout, the undo stack and the autosaved
file, and then Reset or a page reload silently put the old number back. The
autosave recorded the contradiction directly: resources 13 next to
initialResources 10. A nudge at rest now moves the baseline with the count,
while still preserving a mixed holding's colours, which is why it does not just
call setCount.

The "(node deleted)" placeholder added last round was a real selectable option
with an empty value. Choosing it cleared the rule's target, and the next render
fell back to displaying the first interactive node with no warning at all,
which is the exact misleading state the placeholder was added to prevent. It is
disabled now: shown as the current value, impossible to pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
Round-6 findings 13 and 14.

Library Delete, Duplicate and Rename, and the components Delete, all wrote back
the array the list had been rendered from rather than what storage held at the
moment of the edit. So a diagram another tab saved in between was erased along
with the edit: measured through the real Delete menu item, a library holding one
entry from each of two tabs ended up empty. Those edits now re-read storage
first and locate their target by a stable per-entry id, falling back to a
content match for rows saved before ids existed. The save paths already read
fresh, and now stamp an id.

sim_autosave is a single slot shared by every tab on the origin, so the tab that
saves last silently becomes the saved copy while the other carries on believing
its work is safe. Nothing can merge two diagrams, but the superseded tab can be
told, once, while its work is still on screen and can be exported. A storage
listener does that; the event fires only in other tabs, so a write never warns
the tab that made it, and embeds stay silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
…ee panels lying

Round-6 findings 2, 5, 8, 9, 10, 11 and 12.

Drawing a gate's weight label mirrors the engine and evaluates the formula, and
a weight formula can draw random numbers. The canvas repaints on hover, pan,
zoom, selection and every step, so painting was consuming draws the run was
about to make: the same diagram under the same seed produced different results
depending on how much the user moved the mouse. Measured directly: a single
render moved the seeded stream position. Label evaluation now saves and restores
it, the way detectLoops already guards its probes.

The animation layer had no ceiling and no idea when it was off screen. Cleanup
runs from requestAnimationFrame, which a browser suspends in a hidden tab while
the setInterval driving the steps keeps firing, so a run left in another tab
produced dots that nothing consumed: measured growing to 2431 live elements.
Even in the foreground a heavy model at the top of the speed slider held 838 at
once, each costing a getPointAtLength per frame, which is what dragged the page
to about 17fps and made the run fall behind the speed the user asked for. There
is now a hard ceiling of 240 live dots, spawning is skipped entirely while the
document is hidden, and a visibilitychange drops what is in flight and repaints
on return.

Hiding the overflow menu in embeds last round left a narrow embed with no way to
reset a run at all: the small-screen rules hide Reset from the topbar on the
assumption that the menu still carries it. An embed offers Run, Step and Reset,
so Reset comes back at those widths.

Loading a starter template left the Simulation panel showing the empty canvas it
had been drawn against: a blank Name and a FILE block reading 0 nodes and 0
connections beside a canvas full of them. _installTemplate repaints the panel.

Food Web claimed "Ten species lock into coupled, bounded oscillations", but the
Foxes crash to 1 and stay there for the rest of the run while foxBirths sits at
0. This is not a mistuned constant: at three times the birth rate and a quarter
of the death rate they still peak at 167 and collapse to 1, because their
break-even prey population is near the rabbit peak and they eat the base out
from under themselves. That is a real dynamic of this coupling, so the mechanics
are untouched and the description now says what the model does. Retuning the
predator-prey coupling to make foxes persist is a design decision about the demo
rather than a defect fix, and the surrounding web is sensitive to it: pushing
fox efficiency collapsed the rabbit oscillation from a span of 93 to 17.

The smoke suite gained a standing check that every bundled template runs 120
steps with every value finite, since it previously only asserted that they load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH7ouoNymubArm7VEgALHg
@zntznt
zntznt merged commit 948a94d into main Aug 31, 2026
1 check passed
@zntznt
zntznt deleted the claude/ui-ux-run-4qmy5r branch August 31, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants