Skip to content

feat(fresh-ui): safe render_ui renderer#565

Merged
rickylabs merged 3 commits into
mainfrom
feat/258-fresh-ui-genui-renderer
Jul 7, 2026
Merged

feat(fresh-ui): safe render_ui renderer#565
rickylabs merged 3 commits into
mainfrom
feat/258-fresh-ui-genui-renderer

Conversation

@rickylabs

@rickylabs rickylabs commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the Fresh UI consumer renderer for render_ui tool payloads: a bounded recursive renderer with a curated layout / viz / data vocabulary, depth fallback, and unknown-type fallback. The renderer is exposed through @netscript/fresh-ui/ai/render-ui and registered in the existing ai Fresh UI collection.

Scope

Slices

  • FB5 safe render_ui renderer — 92465bf
  • Depth-guard array-branch fix + regression — fe05c81

Definition of Done

  • Consume RenderUiToolInput from @netscript/ai/tools; do not redefine the E4 input contract.
  • Curated vocabulary limited to layout, viz, and data categories.
  • Depth guard regression is implemented and green.
  • Unknown block/type whitelist regression is implemented and green.
  • Happy-path nested layout + viz + data render regression is implemented and green.
  • Renderer registered in the existing ai Fresh UI collection.
  • No payload-derived raw HTML path.
  • gate:e2e full scaffold.runtime generated-project coverage. Deferred to test(cli-e2e): scaffold.runtime coverage for fresh-ui ai generative-ui renderer #564; do not check this box for [AI-stack FB5] fresh-ui: generative-ui-renderer (recursive JSON tree -> curated DS vocabulary) #258.
  • Tier-A slice review complete. (A1 PASS after the fe05c814 array-branch depth fix.)
  • Adversarial security review complete. (IMPL-EVAL constructed an independent 50-deep array re-attack; bounded at depth 7 > maxDepth 6.)
  • IMPL-EVAL PASS.

Validation

  • deno doc --filter RenderUiToolInput packages/ai/tools.ts — exit 0; E4 input is { component, props?, title? }.
  • deno doc --filter renderUiTool packages/ai/tools.ts — exit 0; render_ui is client-deferred.
  • deno check --unstable-kv packages/fresh-ui/src/ai/render-ui.tsx packages/fresh-ui/tests/ai/render-ui.test.tsx — exit 0.
  • deno test --allow-read --unstable-kv packages/fresh-ui/tests/ai/render-ui.test.tsx — exit 0; 4 passed / 0 failed (incl. nested-array depth regression).
  • deno run --allow-read --allow-run .llm/tools/run-deno-check.ts --root packages/fresh-ui --ext ts,tsx — exit 0; filesSelected=128, failedBatches=0, totalOccurrences=0.
  • deno run --allow-read --allow-run .llm/tools/run-deno-lint.ts --root packages/fresh-ui --ext ts,tsx — exit 0; totalOccurrences=0.
  • deno run --allow-read --allow-run .llm/tools/run-deno-fmt.ts --root packages/fresh-ui --ext ts,tsx --ignore-line-endings — exit 0; failedBatches=0.
  • deno test --allow-all packages/fresh-ui/tests/ — exit 0; 132 passed / 0 failed.
  • rtk proxy deno task arch:check — exit 0; warnings only, no FAIL rows.
  • deno run --allow-read --allow-run .llm/tools/run-deno-doc-lint.ts --root packages/fresh-ui --entrypoints ./src/ai/render-ui.tsx --pretty — exit 0; totalErrors=0.
  • deno task doc:lint --root packages/fresh-ui --pretty — exit 0 wrapper; existing interactive.ts doc-lint debt remains, new renderer entrypoint total=0.
  • deno publish --dry-run --allow-dirty from packages/fresh-ui — exit 0; no --allow-slow-types; dry run complete.
  • deno run -A packages/cli/bin/netscript-dev.ts ui:add ai --project-root .llm/tmp/fresh-ui-render-ui-smoke --registry-root packages/fresh-ui --force — exit 0; installed 25 items, copied 47 files, renderer copied to lib/ai/render-ui.tsx.

Harness

  • Run dir: .llm/runs/beta6-nondash--supervisor/
  • Phase: impl
  • Slice artifacts: worklog-258.md, context-pack-258.md, drift-258.md

Drift / Debt

Copy link
Copy Markdown
Owner Author

[PHASE: IMPL] [VERDICT: READY_FOR_A1_REVIEW]

Slice FB5 landed in commit 92465bfa5e415840933d474169e1157166555238.

Scope

  • Added @netscript/fresh-ui/ai/render-ui safe recursive renderer consuming RenderUiToolInput from @netscript/ai/tools.
  • Added curated layout / viz / data vocabulary and safe fallbacks for depth overflow and unknown block types.
  • Registered render-ui in the existing Fresh UI ai collection and regenerated embedded registry content.
  • Added harness artifacts under .llm/runs/beta6-nondash--supervisor/ for [AI-stack FB5] fresh-ui: generative-ui-renderer (recursive JSON tree -> curated DS vocabulary) #258.

Guard Regressions

  • Depth guard: green. Payloads nested beyond RENDER_UI_MAX_DEPTH render data-render-ui-fallback="max-depth".
  • Whitelist guard: green. Unknown block type renders data-render-ui-fallback="unknown-type" and does not carry raw payload markup.
  • Raw HTML path: green. Renderer source has no dangerouslySetInnerHTML / __html path; test asserts this.

Raw Gate Evidence

  • deno doc --filter RenderUiToolInput packages/ai/tools.ts — exit 0; E4 input is { component, props?, title? }.
  • deno doc --filter renderUiTool packages/ai/tools.ts — exit 0; render_ui is client-deferred.
  • deno check --unstable-kv packages/fresh-ui/src/ai/render-ui.tsx packages/fresh-ui/tests/ai/render-ui.test.tsx — exit 0.
  • deno test --allow-read --unstable-kv packages/fresh-ui/tests/ai/render-ui.test.tsx — exit 0; 3 passed / 0 failed.
  • deno run --allow-read --allow-run .llm/tools/run-deno-check.ts --root packages/fresh-ui --ext ts,tsx — exit 0; filesSelected=128, failedBatches=0, totalOccurrences=0.
  • deno run --allow-read --allow-run .llm/tools/run-deno-lint.ts --root packages/fresh-ui --ext ts,tsx — exit 0; totalOccurrences=0.
  • deno run --allow-read --allow-run .llm/tools/run-deno-fmt.ts --root packages/fresh-ui --ext ts,tsx --ignore-line-endings — exit 0; filesSelected=128, failedBatches=0.
  • deno test --allow-all packages/fresh-ui/tests/ — exit 0; 132 passed / 0 failed.
  • rtk proxy deno task arch:check — exit 0; warnings only, no FAIL rows.
  • deno run --allow-read --allow-run .llm/tools/run-deno-doc-lint.ts --root packages/fresh-ui --entrypoints ./src/ai/render-ui.tsx --pretty — exit 0; totalErrors=0.
  • deno task doc:lint --root packages/fresh-ui --pretty — exit 0 wrapper; existing interactive.ts debt remains, renderer entrypoint total=0.
  • deno publish --dry-run --allow-dirty from packages/fresh-ui — exit 0; no --allow-slow-types; dry run complete.
  • deno run -A packages/cli/bin/netscript-dev.ts ui:add ai --project-root .llm/tmp/fresh-ui-render-ui-smoke --registry-root packages/fresh-ui --force — exit 0; installed 25 items, copied 47 files, renderer copied to lib/ai/render-ui.tsx.

Deferred / Not Certified

@rickylabs

Copy link
Copy Markdown
Owner Author

[PHASE: A1 SLICE REVIEW] [Tier-A supervisor — FAIL_FIX, fix dispatched to generator]

Substantive review of the render_ui renderer. The slice is largely strong — but the security-critical depth guard has one reachable bypass that must be fixed before merge.

Blocking finding — nested-array depth-guard bypass (reachable DoS)

renderNode's array branch recurses at the same depth, not depth + 1:

if (Array.isArray(node)) {
  return node.map((child, index) => (<span >{renderNode(child, depth, context)}</span>));
}

So the depth > maxDepth guard never trips for nested arrays — and this is reachable from validated input. The core render_ui validator (packages/ai/src/tools/domain/render-ui.ts) checks only that props is an object; its interior is Record<string, unknown>, entirely unvalidated (additionalProperties: true), by design ("the downstream renderer resolves"). A payload { component: "stack", props: { children: [[[[ … ]]]] } } therefore passes validation and drives renderNode → map → renderNode at constant depth until the JS call stack overflows → SSR render crash. This is a denial-of-service in exactly the vector the depth guard exists to defend.

Verdict: FAIL_FIX. One-line fix (recurse the array branch at depth + 1) + a non-deferrable regression test (nested-array payload beyond RENDER_UI_MAX_DEPTH must yield the max-depth fallback, not throw). Dispatched to the generator thread now.

Verified clean (no rework needed)

  • Whitelist correct. Payload type is only ever used to select among hardcoded JSX tags via a frozen BLOCK_TYPES Set; unknown types → unknown-type fallback. It is never used as the created element type.
  • No raw-HTML path. No dangerouslySetInnerHTML/__html anywhere; a test reads the renderer source and asserts their absence, and asserts the unknown-type fallback carries no raw payload markup. Chart value is validated numeric before style interpolation.
  • Gate discipline correct. gate:e2e is left unchecked with an explicit "Deferred to test(cli-e2e): scaffold.runtime coverage for fresh-ui ai generative-ui renderer #564" note; follow-up test(cli-e2e): scaffold.runtime coverage for fresh-ui ai generative-ui renderer #564 filed. No falsified boxes.
  • Hygiene. No deno.lock diff; no new as casts; JSDoc + @module on exports; existing 3 tests green locally.

Merge held behind the fix + re-review + IMPL-EVAL (separate OpenHands session) + the #258 close-gate. This bypass is the kind of defect the B→D lane-override adversarial mitigation existed to catch.

Copy link
Copy Markdown
Owner Author

[PHASE: IMPL] [VERDICT: READY_FOR_A1_REVIEW]

A1 FAIL_FIX for the nested-array depth guard bypass is pushed.

Scope:

  • renderNode() now renders array children with depth + 1, so array nesting is bounded by the same RENDER_UI_MAX_DEPTH guard as block/children nesting.
  • The first guard in renderNode() still short-circuits to renderFallback('max-depth') once the bound is crossed.
  • Added a non-deferrable regression with a 50-deep nested array in props.children; it asserts the render does not throw and the serialized output contains data-render-ui-fallback="max-depth".
  • Existing whitelist/raw-HTML guard behavior was not weakened.

Commit: fe05c814 (fix(fresh-ui): bound render_ui array nesting by depth guard)

Raw gate evidence:

  • deno check --unstable-kv packages/fresh-ui/src/ai/render-ui.tsx packages/fresh-ui/tests/ai/render-ui.test.tsx -> exit 0
  • deno test --allow-read --unstable-kv packages/fresh-ui/tests/ai/render-ui.test.tsx -> exit 0; 4 passed / 0 failed
  • deno run --allow-read --allow-run .llm/tools/run-deno-check.ts --root packages/fresh-ui --ext ts,tsx -> exit 0; filesSelected=128; failedBatches=0; totalOccurrences=0
  • deno run --allow-read --allow-run .llm/tools/run-deno-lint.ts --root packages/fresh-ui --ext ts,tsx -> exit 0; filesSelected=128; totalOccurrences=0
  • deno run --allow-read --allow-run .llm/tools/run-deno-fmt.ts --root packages/fresh-ui --ext ts,tsx --ignore-line-endings -> exit 0; filesSelected=128; failedBatches=0
  • deno run --allow-read --allow-run .llm/tools/run-deno-doc-lint.ts --root packages/fresh-ui --entrypoints ./src/ai/render-ui.tsx --pretty -> exit 0; totalErrors=0
  • deno publish --dry-run --allow-dirty from packages/fresh-ui -> exit 0; no --allow-slow-types; dry run complete
  • rtk proxy deno task arch:check -> exit 0; warnings only, no FAIL rows
  • deno test --allow-all packages/fresh-ui/tests/ -> exit 0; 133 passed / 0 failed

Lock hygiene: deno.lock unchanged. Worktree clean after push.

Guard regressions green:

  • depth guard: green
  • nested-array depth bypass regression: green
  • whitelist guard: green
  • happy path: green

gate:e2e remains deferred to #564 as previously recorded. This is not self-certification; ready for Tier-A re-review, then adversarial review and IMPL-EVAL.

@rickylabs

Copy link
Copy Markdown
Owner Author

[PHASE: A1 SLICE REVIEW] [Tier-A supervisor sign-off — PASS]

Re-review after the FAIL_FIX fix (fe05c814). The blocking nested-array depth-guard bypass is resolved.

Fix verified

  • renderNode's array branch now recurses at depth + 1 (line 163), so array nesting is bounded by the same maxDepth guard as block/children nesting. A pathological props.children: [[[[…]]]] payload now yields the bounded max-depth fallback instead of overflowing the stack.
  • New non-deferrable regression test renderUiPayload bounds nested arrays by the max depth guard builds nestedArray(50) and asserts the serialized output carries data-render-ui-fallback="max-depth". It passes now and fails against the old same-depth code (which never emits a fallback for arrays) — so it genuinely captures the fix. All 4 tests green locally.

Still verified (unchanged by the fix)

A1 PASS. Proceeding to IMPL-EVAL (separate OpenHands session, security-hardened re-attack of the depth + whitelist guards). Merge held behind IMPL-EVAL PASS + the #258 close-gate. The adversarial mitigation the B→D lane override called for was performed at A1 (this bypass was caught and fixed before any eval cycle was spent).

@rickylabs

Copy link
Copy Markdown
Owner Author

@openhands-agent model=openrouter/qwen/qwen3.7-max output=pr-comment iterations=1500

use harness

IMPL-EVAL — FB5 render_ui generative-UI renderer (issue #258, PR #565)

You are the IMPL-EVAL evaluator (separate session from the Tier-D generator) for one slice of the
beta6-nondash--supervisor harness run. Branch feat/258-fresh-ui-genui-renderer, base b5d09693,
slice commits 92465bfa (renderer) + fe05c814 (depth-guard fix). Read-only. The Tier-A supervisor
already caught and had fixed a reachable nested-array depth-guard bypass at A1; your job is to
independently re-attack the security guards and confirm — or refute — that this renderer is
merge-safe.

Run the cheapest decisive checks FIRST, form a verdict, POST it, then spend remaining budget.

SKILL

DECISIVE CHECKS — in order, post verdict after check 4

  1. Security — depth guard (the core acceptance; ADVERSARIAL). Confirm EVERY recursive descent in
    packages/fresh-ui/src/ai/render-ui.tsx increments depth: block children (renderChildren
    depth + 1), list items (depth + 1), AND the array branch of renderNode (depth + 1 — this
    was the A1 fix). Construct your OWN adversarial payload with props.children nested as an
    array-of-arrays well beyond RENDER_UI_MAX_DEPTH (the schema permits it — props interior is
    unvalidated) and confirm the renderer returns the bounded max-depth fallback and does NOT throw
    RangeError. If any descent path can recurse without incrementing depth → FAIL_FIX.
  2. Security — whitelist. Confirm the payload type/component is only ever used to SELECT among
    hardcoded JSX tags (never used as the created element type), unknown types render the
    unknown-type fallback, and there is no dangerouslySetInnerHTML/__html / raw-markup path.
    Feed a payload with a <script>/onerror string and confirm it is escaped, not injected.
  3. Contract seam. The renderer consumes E4's RenderUiToolInput from @netscript/ai/tools
    (not a re-declared local shape), and it extends the existing ai fresh-ui collection registry
    (registry.manifest.ts / regenerated registry.generated.ts) rather than inventing a new one.
  4. Tests + gates + hygiene (decisive). deno test --allow-all --unstable-kv packages/fresh-ui/tests/ai/render-ui.test.tsx green (4 cases incl. the nested-array regression);
    run-deno-doc-lint.ts --root packages/fresh-ui totalErrors=0 for touched exports; deno publish --dry-run from packages/fresh-ui exit 0 WITHOUT --allow-slow-types; deno task arch:check
    green; git diff b5d09693..HEAD -- deno.lock empty; no as casts beyond the 2 accepted. →
    FORM VERDICT AND POST THE PR COMMENT NOW.
  5. (If budget remains) Confirm the nested-array regression test genuinely fails against the
    pre-fix 92465bfa array branch (same-depth) and passes at fe05c814.

Rules

  • Read-only. NEVER mutate deno.lock (git checkout -- deno.lock before any commit). TRUE base
    b5d09693.
  • Write your verdict to .llm/runs/beta6-nondash--supervisor/evaluate-258.md, commit it to THIS PR
    branch, post the PR comment. End both with the undecorated first-token OPENHANDS_VERDICT: line:
    PASS / FAIL_FIX / FAIL_RESCOPE / FAIL_DEBT.

OUTPUT CONTRACT (mandatory — verdict first)

  1. Post the verdict PR comment IMMEDIATELY after you form the verdict — BEFORE any
    optional deep-dive, extra verification, or long context dump. Iteration budgets
    exhaust; a verdict comment deferred to the end of the run is frequently lost.
  2. That PR comment MUST start with the formal header line, exactly:
    [PHASE: ] [VERDICT: ]
    where is your eval phase (e.g. IMPL-EVAL, PLAN-EVAL) and is one
    of PASS, FAIL_FIX, FAIL_RESCOPE, FAIL_DEBT, FAIL_PLAN.
  3. ALWAYS end BOTH the verdict PR comment AND your summary file with one final
    machine-readable line of the exact form:
    OPENHANDS_VERDICT:
    using a literal token from: PASS, FAIL_FIX, FAIL_RESCOPE, FAIL_DEBT, FAIL_PLAN,
    NONE. Use NONE only when no verdict could be reached.

@rickylabs

rickylabs commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

OpenHands Agent — Completed

OPENHANDS_VERDICT: PASS

Model: openrouter/qwen/qwen3.7-max
Provider: OPENROUTER
Job status: success
Verdict source: summary-file
Verdict comment: #565 (comment)
Summary source: agent
Housekeeping: ack=success trace=success commit-artifacts=success replies=skipped draft-pr=skipped
Trace: .llm/tmp/run/openhands/pr-565/run-28856577359-1 (in the Actions artifact)
Artifact: openhands-agent-28856577359-1

IMPL-EVAL Summary: FB5 render_ui generative-UI renderer (#258, PR #565)

Summary

Independent IMPL-EVAL security re-attack of the Fresh UI render_ui payload renderer for issue #258. Branch feat/258-fresh-ui-genui-renderer, base b5d09693, slice commits 92465bfa (renderer) + fe05c814 (A1 depth-guard fix).

Changes

  • Added .llm/runs/beta6-nondash--supervisor/evaluate-258.md — full evaluation artifact with all decisive check results

Validation

Decisive Checks (all PASS)

Check Evidence
1. Depth guard (adversarial) All 3 recursive descent paths increment depth: renderChildren (line 344), list items (line 290), array branch (line 163 — A1 fix). 50-deep array nesting bounded at depth=7 > maxDepth=6.
2. Type whitelist / XSS BLOCK_TYPES hardcoded set (8 blocks), unknown-type → static div, no dangerouslySetInnerHTML/__html, <script>alert("owned") escaped not injected.
3. Contract seam import type { RenderUiToolInput } from '@netscript/ai/tools' (line 7), renderer registered in existing ai collection, registry.generated.ts includes RenderUiSurface.
4. Tests + gates deno test --allow-all --unstable-kv exit 0 (4 passed / 0 failed, incl nested-array regression), run-deno-doc-lint totalErrors=0 for touched exports, deno publish --dry-run exit 0 WITHOUT --allow-slow-types, deno task arch:check exit 0 (no FAIL rows), git diff deno.lock empty, 0 as casts (under 2-accepted budget).
5. A1 fix verification Pre-fix 92465bfa array branch: renderNode(child, depth, context) (bypass). Post-fix fe05c814: renderNode(child, depth + 1, context) (guarded).

Adversarial Depth Trace

Constructed 50-level array nesting in props.children:

  • renderBlock depth=0 → renderChildrenrenderNode depth=1
  • Array branch fires 6×: depth 1→2→3→4→5→6→7
  • At depth=7 > RENDER_UI_MAX_DEPTH (6) → renderFallback('max-depth')
  • No descent path bypasses depth increment.

Responses to Review Comments

N/A (fresh evaluation session).

Remaining Risks

  • gate:e2e full scaffold.runtime generated-project coverage is intentionally deferred to test(cli-e2e): scaffold.runtime coverage for fresh-ui ai generative-ui renderer #564 (correct per contract).
  • Tier-A slice review and adversarial security review boxes in PR body remain unchecked — those are human-process steps, not agent gates.
  • Full fresh-ui deno doc --lint still carries existing interactive.ts doc-lint debt (123 errors, none in touched render-ui.tsx entrypoint).

OPENHANDS_VERDICT: PASS

Run: https://github.com/rickylabs/netscript/actions/runs/28856577359

@rickylabs

Copy link
Copy Markdown
Owner Author

[PHASE: IMPL-EVAL] [VERDICT: PASS]

IMPL-EVAL: FB5 render_ui generative-UI renderer (#258)

Independent security re-attack complete. All decisive checks pass.

Decisive Checks Summary

Check Result
1. Depth guard (adversarial) ✅ All 3 recursive paths increment depth. 50-deep array nesting bounded at depth=7 > maxDepth=6.
2. Whitelist / XSS ✅ Hardcoded 8-block vocabulary, no dangerouslySetInnerHTML, <script> escaped not injected.
3. Contract seam ✅ Consumes RenderUiToolInput from @netscript/ai/tools, registered in existing ai collection.
4. Tests + gates ✅ 4/4 tests green, doc-lint totalErrors=0, publish dry-run exit 0, arch:check green, deno.lock empty, 0 as casts.
5. A1 fix verification ✅ Pre-fix 92465bfa array branch: depth (bypass). Post-fix fe05c814: depth + 1 (guarded).

Adversarial Depth Trace

Constructed 50-level array nesting in props.children:

  • renderBlock depth=0 → renderChildrenrenderNode depth=1
  • Array branch fires 6×: 1→2→3→4→5→6→7
  • At depth=7 > RENDER_UI_MAX_DEPTH (6) → renderFallback('max-depth')
  • No descent path bypasses depth increment.

Security Boundary Assessment

The renderer correctly self-bounds against AI-tool-controlled payloads:

  1. Type whitelist closed — only {stack, grid, section, chart, metric, table, list, card} rendered; unknown types → static fallback div
  2. No raw HTML path — all strings rendered as JSX text children (Preact auto-escaping), no dangerouslySetInnerHTML/__html
  3. Depth guard complete — every recursive descent increments depth (block children, list items, array branch)
  4. Contract isolation — consumes E4 validated input, does not redefine schema

PR Body Close-Gate

Verdict

PASS — Merge-safe. The renderer is a robust security boundary for generative-UI payloads.

OPENHANDS_VERDICT: PASS

Independent security re-attack confirms:
- Depth guard complete on all 3 recursive descent paths
- Type whitelist closed, no raw HTML path
- Contract seam correct (consumes E4 input, existing registry)
- All gates green: tests, doc-lint, publish dry-run, arch:check, lock hygiene
- A1 fix verified (pre-fix bypass confirmed, post-fix guard confirmed)

Co-authored-by: openhands <openhands@all-hands.dev>
@rickylabs
rickylabs marked this pull request as ready for review July 7, 2026 16:37
@rickylabs
rickylabs merged commit c8f6872 into main Jul 7, 2026
14 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants