Skip to content

fix(fresh-ui): shipped render_ui embed has an unbounded-recursion hole — depth guard cannot trip on nested arrays #773

Description

@rickylabs

Summary

packages/fresh-ui/registry.generated.ts — the copy-source embed that is copied into user projects — is stale with respect to its source, and the staleness is not cosmetic: it ships a render_ui renderer whose recursion-depth guard cannot fire on nested arrays.

This is the same shape as #769: what users get is not what we test.

The defect

renderNode array branch
Sourcepackages/fresh-ui/src/ai/render-ui.tsx:163 renderNode(child, depth + 1, context)
Shipped embedpackages/fresh-ui/registry.generated.ts renderNode(child, depth, context)

The guard is:

function renderNode(node: unknown, depth: number, context: RenderContext): ComponentChildren {
  if (depth > context.maxDepth) return renderFallback('max-depth');
  ...
  if (Array.isArray(node)) {
    return node.map((child, index) => (
      <span class='ns-render-ui__fragment' data-index={index}>
        {renderNode(child, depth, context)}   // ← depth NOT incremented in the embed
      </span>
    ));
  }

Object nesting increments correctly (renderChildrenrenderNode(children, depth + 1, …)). Array nesting does not. So RENDER_UI_MAX_DEPTH = 6 is unreachable via arrays: a payload of nested arrays recurses at constant depth and the depth > maxDepth fallback never triggers.

Why it matters

render_ui exists to render LLM-generated tool payloads — untrusted-by-construction input. The renderer is explicitly documented as "Safe generative-UI renderer" and "renders … into safe, bounded Preact DOM." The bound is the whole point of the component, and in the shipped copy it does not hold for the one input shape an LLM most easily produces by accident: nested arrays.

A malformed or adversarial payload of sufficiently nested arrays exhausts the stack.

Why every gate missed it

The gates run against source, which is correct. The embed is a generated artifact that is not regenerated in CI and not diffed against source. Nothing compares the two. So a fix landed in source (#565) and never reached the artifact users receive.

Scope / repro

  • Affected: any consumer that copied fresh-ui's render-ui unit from the registry embed.
  • Not affected: tools/design-sync — it reads registry.manifest.ts + real source files and never parses the embed (verified).

Fix

  1. Regenerate registry.generated.ts from source (this alone closes the hole — but the diff carries a behaviour change and must be reviewed, not rubber-stamped).
  2. Add a CI gate that fails when the generated embed differs from a fresh regeneration. Without it, this recurs by construction — a generated artifact nobody regenerates is a lie with a timestamp.
  3. Add a test asserting the depth guard fires on array nesting, not just object nesting. The existing tests pass against source, so they prove nothing about the embed.

Provenance

Found during the beta.10 run when a Codex slice tried to sweep a registry.generated.ts modification into an unrelated p0 PR. The contamination was rejected (unrelated executable scope must not ride into a p0), which is what surfaced the underlying bug rather than laundering it.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions