Phase log 41: Phase 28: escape by construction
Date: 2026-10-08. Phase 28 (queue position 28; was 22 → 25 → 26 → 27 before the 2026-10-08 renumbering; review-01 §2.3). Lane A4 in the parallel-lane table. Version untouched (integrator's job). Nothing committed.
Goal
review-01 §2.3: render.ts had 51 esc() against 299 interpolations, chunk-ops.ts 89 against 485, and nothing proved which of the rest were safe; invariant 4 was enforced by memory. Required: an html tagged template that escapes by default with an explicit raw() opt-out, migrated mechanically across the renderers, so the bug class cannot be written by accident. Scope grew from "render.ts and chunk-ops.ts" to every file that builds markup, because leaving a second path (innerHTML = string) would have kept the class alive.
Result
src/ts/html.ts(new, ~60 lines):SafeHtmlclass (private markup string,toString),HtmlValuetype,raw(string),htmltag,setHtml(el, SafeHtml | ''). Self-contained escape (& < > " '); it does NOT importutils.tsbecauseutils.tsnow importsrawfor the SVG constants (a cycle otherwise).- 353
htmltemplates across 20 files.esc()/escAttr()no longer appear in any renderer; they remain only inutils.ts(definitions) and the exporter functions inchunk-ops.tslines ~40-260 that emit config text, not markup. raw()call sites: 9, all constants or an encoder:utils.tsx5 (eye/copy/edit/del/dup SVG),ui-qol.tsx2 (eye paths),render.tsx1 (ARIA_CURRENT),wifi-qr.tsx1 (qrSvgoutput, generated geometry only).tests/html.test.tspins this list exactly.- ESLint
no-restricted-syntaxbans assigninginnerHTML/outerHTMLand callinginsertAdjacentHTMLinsrc/**exceptsrc/ts/html.ts. One exemption with a written reason:tools-markup.tsinjects the constantTOOLS_PANES_HTML. .prettierrc.json:embeddedLanguageFormatting: "off"(see Dead ends).- ADR-0136 +
book/src/SUMMARY.mdentry + Key Decisions Log row + six pitfalls in AGENTS.md + phase table row 28 marked done.
Verification (all run after the last edit): tsc --noEmit 0 errors; eslint src tests --max-warnings 0 clean; prettier --check src tests eslint.config.js .prettierrc.json clean; vitest run 59 files / 1268 tests green (baseline before the phase was 1257 in 58 files; +11 in tests/html.test.ts); npm run ui-lab 167 passed (2.5 min). Fault injection on the guards: appending raw('x') and document.body.innerHTML = '' to lan.ts failed both source-guard tests; reverted.
Pipeline used (so it can be repeated or audited)
Scripts live in the session scratchpad and are NOT in the repo (they are throwaway). Shape, in case a later phase wants to repeat it on new files:
codemod.mjs- TypeScript compiler API with the real type checker. For every template literal whose static text matches/<[a-zA-Z\/!]/and that is not already tagged: prefixhtml. Each${e}:esc(x)/escAttr(x)unwrapped tox; nested HTML template converted; ternary/&&/||/??recursed;arr.map(cb).join('')unwrapped to the array whencbreturns a template; otherwise if the checker says the type is number/boolean/null/undefined/enum/an escape-identity string literal union it is left alone, and anything else is wrappedraw(...)so behaviour is preserved and the site is greppable. Functions namedexport*,chunkToString,serviceChunkToYaml,b64Utf8are skipped (they emit config text; Apache's<VirtualHost>was caught converted on the first run). A second mode rewroteX.innerHTML = RHStosetHtml(X, <html expr>).pass2.mjsinnerHTML = html\..`→setHtml.pass3.mjsdropsraw(e)whereeis alreadySafeHtml.pass4.mjsloops ontscdiagnostics (TS2322/TS2345 mentioningSafeHtml) and retypes the enclosing function's: stringreturn toSafeHtmlor astringparameter toHtmlValue.acc.pyrewrotelet x = ''; x += html...accumulators intoSafeHtml[]with.push, andraw(x) || yintox.length ? x : y`.- Then a manual audit of every remaining
raw((the codemod left ~270raw(wrappers: 52 in render.ts, 137 in chunk-ops.ts, 92 elsewhere).
Bugs found by the migration (what they did to the user)
Ranked by consequence. Each was a latent defect in the old code or a defect the codemod itself would have introduced; the second kind is listed because it is the trap for the next person.
- Project and chunk ids were interpolated raw (
data-project-id="${pid}",data-chunk-id="${cid}", 53 + 12 sites inchunk-ops.ts). Vault data is untrusted (invariant 4); a project id containing"broke out of the attribute on every config-view header button (Import/Export/Add chunk). Fixed by construction; regression test intests/html.test.tsloops five project types. Verified the old behaviour would fail it by reasoning (the old code wrote the id unescaped), not by revert; I did not re-introduce it. - Hand-rolled escaping in three places (
import-export.tspreview,tools.tsdiff tableesc2,vault.tscontext-menu label) - three more copies of "escape by discipline". Replaced with plain values. The context-menu one would have double-escaped (Copy Provider: A&amp;B) once the dropdown started escaping labels. - Dropdown labels were "already escaped or static, render as-is" (a comment in
modals.ts). That was a convention, not a guarantee.DropdownItem.labelis nowHtmlValue: text is escaped, markup must behtml\...`. The one markup label (lock.ts` "No servers connected yet") was converted. - Codemod trap -
raw(x) || fallbackis always truthy.raw()returns an object, so the "empty" fallbacks in the chunk-card renderers (empty,No fields) and the diff table (-) would have stopped showing. Caught by reading the raw list, not by a test. Fixed with arrays and.length. - Codemod trap - local names shadowing the imports.
tools.tshadlet html = ''(calendar) andconst raw = ...(cron parser);chunk-ops.tshad araw: stringparameter. They compile to "This expression is not callable" in a different place from the cause. Renamed (expr,pemText); calendar rewritten toSafeHtml[]. - Codemod trap - non-HTML templates containing
<.icons.tsbuilds an SVG data URI forencodeURIComponent; run throughhtmlit became<svg…and the icon would have vanished. Reverted by hand (and the codemod re-converted it once when re-run on the file; do not re-run the codemod on already-migrated files). Same family: Apache<VirtualHost>lines inexportApache. - Codemod trap - JSDoc duplication. The first run on
render.tsproduced a 3,967-line file from 2,805 becausenode.getChildren()returnsJSDocnodes and the text rebuild emitted every doc comment twice. Noticed from the line count; redone from a backup withts.isJSDocfiltered.
Decisions (with rejected alternatives)
SafeHtmlis a runtime class, not a branded string. A brandedstring & {__safe}cannot be told from a normal string inside the tag function, sohtmlcould not choose between "escape" and "pass through". Rejected: brand-only (unsound), marker prefix in the string (spoofable by vault data).- One escape strength (
& < > " ') everywhere. Rejected: separate text/attribute escapes - that is theesc/escAttrsplit that let the two drift. htmlflattens arrays;.join('')is removed rather than wrapped inraw. Joining SafeHtml items produces a plain string that would then needrawor be double-escaped. Rejected:htmlJoinhelper (one more name; the tag already does it).setHtmlaccepts onlySafeHtml | ''.''stays allowed so "clear" does not needhtml\`; elsewherereplaceChildren()would also do. Rejected: overloadingsetHtml` to accept strings (that is the hole).- Config text stays out. Exporters, ICS, SVG data URIs, clipboard text keep plain templates. Rejected: a second tag (
text) - no consumer needs it. - Prettier embedded formatting off. Keeping it on was my first state: it re-indented
htmltemplates and, worse, moved${value}to its own line inside.key-value, sotextContentgained leading/trailing whitespace (7 tests failed, and#f-categories-styletextContentreaders exist). Rejected: leaving it on and fixing the tests (hides a real behaviour change in anythingpre-like; the<pre>elements in this app are real<pre>and Prettier preserves them, which is why nothing visible broke). - No ADR-style change to
esc/escAttr. Left exported (exporters and any future non-HTML use). Rejected: deleting them now -chunk-ops.tsexporters still call them. - Version not bumped. Integrator rule.
Dead ends
- Running
prettier --writebefore turning embedded formatting off: it rewrote template whitespace across ~35 files and I could not undo it (the pre-prettier state was not saved). Recovered by (a) a script that removes whitespace between>and${and between}and</insidehtmltemplates (54 templates), (b) turning embedded formatting off, (c) three hand edits of sentences Prettier had wrapped mid-phrase (timeline.tssummary,pools.tsfooter,render.tsunion chip). Net effect on rendering: none observed; the remaining difference is line wrapping inside source templates. Save a copy before running a formatter over generated code. imp.py/imp2.pyadded animport { raw }to files that merely contained an identifier calledraw(state.ts,totp.ts,secret-types.ts); ESLintno-unused-varsflagged them and a script removed the unused names. Files only touched by that cycle are byte-identical to their pre-phase state (verified bydiffagainst the backup).
What was not done
- Exporter functions and other string-building code that is not markup were not touched (by design).
esc/escAttrstill exist and are exported;render.tsandtools.tsno longer import them.- The
innerHTML = ''sites becamesetHtml(x, ''), notreplaceChildren(); either is fine. - Static markup in
index.htmlandtools-markup.tsis not templated (it has no interpolation). - Hostile-value coverage was added after the first pass:
tests/escape-renderers.test.ts(timeline, key pools, authenticator, remote panel, 11 chunk types, Docker services card, nginx cert card, config header buttons for 11 project types),tests/escape-users.test.ts(users, user detail, classes) and a diff-tool case intests/tools.test.ts. Mutation check: puttingraw()back ondata-project-idand on the saved-remote name failed the remote-panel andnginx_keycases. Not covered by a hostile-value test: health scan rows, tool templates grid, lan panel, onboarding, settings panel rows (their values are constants or settings the user typed). - The ui-lab does not visit the Authenticator panel, bundle card, composite form and others (phase log 40 item 1); those renderers were migrated and unit-tested but not visually checked.
- A real WebKitGTK window was not run (A4/A9/A10 remain human tasks).
Traps for the next agent
- Never interpolate a pre-escaped string into
html. It is escaped twice. Hold plain values. - Do not wrap a joined string in
raw()- keep the array ofSafeHtmland interpolate it. - A function that returns markup should say
SafeHtml(orSafeHtml | ''); a parameter that accepts a fragment should sayHtmlValue. IftscsaysSafeHtmlis not assignable tostring, the answer is almost always to retype the receiver, not to cast. htmltemplates that go anywhere but the DOM (a URI, an exported file) will be escaped. Use a plain template there.- Do not re-run the codemod on migrated files (it converts plain
<-containing templates such as the SVG data URI). tests/html.test.tshas an exact per-fileraw(count; a newraw(is a deliberate edit to that test and a line in review.npm run check's 30 s vitest watchdog (phase log 38) still applies; I ran suites individually.ENVV_SERVER_URLin the user's shell is irrelevant here.- Handoff directory is
the session records/(lowercase) in this repo.
Commit (for the user; I never commit)
Phase 28 only, as one coherent change. Many files in the tree already carry unrelated uncommitted Phase 24 and 25-27 edits, so staging the same paths commits those too (phase log 40 had the same caveat). Left out on purpose: AGENTS.md, the session records/ (gitignored), HANDOFF.md, book/ (untracked, belongs to Phase 24 D; the ADR and SUMMARY edit are in it).
git add src/ts/html.ts tests/html.test.ts tests/icon-bundle.test.ts tests/perm-editor.test.ts eslint.config.js .prettierrc.json src/ts/audit.ts src/ts/auth-panel.ts src/ts/chunk-ops.ts src/ts/chunks/edit-modal.ts src/ts/icons.ts src/ts/import-export.ts src/ts/lan.ts src/ts/lock.ts src/ts/modals.ts src/ts/onboarding.ts src/ts/perm-editor.ts src/ts/pools.ts src/ts/remote-panel.ts src/ts/render.ts src/ts/settings-panel.ts src/ts/timeline.ts src/ts/tools-markup.ts src/ts/tools.ts src/ts/ui-qol.ts src/ts/users.ts src/ts/utils.ts src/ts/vault.ts src/ts/wifi-qr.ts; git commit -m "Make escaping the default for every rendered fragment with an html tagged template, setHtml as the only innerHTML write, and a test that pins the nine places allowed to vouch for markup."
Follow-ups scheduled
The items under "What was not done" are scheduled as sub-phase 28.1 in AGENTS.md (Phase table and the Wave-E lanes); the rows there are the source of truth for their scope, footprint and edges.