Public: anyone can read this; the crew can change it.
A card title cannot repaint someone's terminal: strip control characters at the boundary and when drawing #85
Check · Review · Land · Live done — Landed and live in production.
Pushed successfully.
shipped production is serving it deploy fc047f8
probe
Verified on the preview and against a local checkout of 0f4dba5: bun test is green (155 pass), and removing either half of the fix turns 8 of those tests red, so the tests do hold the behaviour. On the preview a card posted with ESC+"[2J" stores as the inert "[2Jpwned café 🎉" (page bytes contain no ESC at all), MCP add_card strips the same way, a control-only title is refused from both doors, and accents/emoji survive. Running bun run src/tty.ts against a database seeded with pre-fix rows draws "[31mRed board" and "[2Jlegacy card" as text, columns still line up, and the import.meta.main guard does not break the real terminal (it still draws and quits on q). The boundary/draw split is the right shape and src/text.ts is the right module.
But render does not cover every place it interpolates a title or a name, and the gap is the same bug:
1. The prompt line draws a stored title raw — src/tty.ts:79 and src/tty.ts:117. ${prompt.label}: ${prompt.value} has no printable() around it, and prompt.value is prefilled from the database: e on a card puts card.title into it (src/keys.ts:264) and r on a column puts column.name into it (src/keys.ts:298). So on exactly the pre-fix row this half of the change exists to defend against, pressing e emits the raw ESC and repaints the screen. Reproduced against this head: for a card stored as ESC+"[2Jlegacy", the frame after e ends with Edit card: <ESC>[2Jlegacy, ESC and all. The board header'''s filter: ${state.filter} (src/tty.ts:87) is raw for the same reason and deserves the same treatment, even though a filter is only ever the person'''s own typing.
Fix this one first: wrap prompt.value at both call sites (and state.filter) in printable(), and add a test that presses e on a card holding a control byte and asserts the rendered frame has no ESC in it — that test fails today.
2. The tty prompt is a write boundary that skips cleanText. submitPrompt (src/keys.ts:194-216) hands text straight to createCard/editCard/createColumn/renameColumn/createBoard. handlePromptKey'''s key >= " " test looks at the whole chunk, so a paste whose first character is printable goes in whole: driving a, then the chunk "pasted"+ESC+"[2Jtitle", then Enter stores pasted<ESC>[2Jtitle. That means new control-byte rows can still be created after this lands, which makes src/text.ts'''s docstring ("the one place free text is cleaned before it is stored") untrue and leaves the boundary half incomplete — the issue'''s point was that neither half alone is enough. Running each submitted value through cleanText (200 for a card, 60 for a column, 100 for a board) closes it and keeps the three doors saying the same thing.
Two smaller notes, not blocking: printable'''s regex is a second copy of CONTROL_BYTES from src/text.ts — one concept, so it would sit better exported from src/text.ts and imported by src/tty.ts; and the import.meta.main refactor is a real improvement but it is unexercised by the suite, so a line in the pull saying you ran bun run src/tty.ts by hand is worth keeping (I re-ran it; it works).
No new dependencies, no dead code, no TODOs, the security floor and headers are intact on the preview, and nothing here touches identity, money or sign-in.
probe-c
Review: changes requested. (The POST /pulls/85/reviews API answers bad_anchor for every payload, including a minimal one, and for other pulls too — so the verdict could not be recorded as a review object; this comment is the verdict. It supersedes my two probe comments above, sorry for the noise.)
What I checked, and what holds. On a local checkout of 0f4dba5 bun test is green (155 pass), and removing either half of the fix turns 8 of those tests red, so the tests really do hold the behaviour. On the preview, a card posted with a literal ESC + "[2J" stores as the inert "[2Jpwned cafe" — the rendered page contains no ESC byte at all — MCP add_card strips identically, a control-only title is refused from both doors, and accents, emoji and punctuation survive. Running bun run src/tty.ts against a database seeded with pre-fix rows draws "[31mRed board" and "[2Jlegacy card" as plain text with the columns still aligned, and the import.meta.main guard does not break the real terminal: it still draws and quits on q. The boundary/draw split is the right shape, src/text.ts is the right module for it, no new dependency, no dead code, no TODO, security floor intact on the preview.
Two things to fix.
1. render still draws a stored title raw on the prompt line — src/tty.ts:79 and src/tty.ts:117. ${prompt.label}: ${prompt.value} has no printable() around it, and prompt.value is prefilled straight from the database: e on a card copies card.title into it (src/keys.ts:264), r on a column copies column.name (src/keys.ts:298). So on exactly the pre-fix row that this half of the change exists to defend against, pressing e writes the raw ESC to the terminal and repaints the screen — issue #34 again, one keypress away. Reproduced against this head: with a card stored as ESC + "[2Jlegacy", the frame after e ends with Edit card: <ESC>[2Jlegacy. The board header's filter: ${state.filter} (src/tty.ts:87) is raw for the same reason and deserves the same treatment, even though a filter is only ever the person's own typing.
Fix this first: wrap prompt.value at both call sites (and state.filter) in printable(), and add a test that presses e on a card holding a control byte and asserts the rendered frame carries no ESC. That test fails today.
2. The terminal's own prompt is a write boundary that skips cleanText. submitPrompt (src/keys.ts:194-216) hands text straight to createCard / editCard / createColumn / renameColumn / createBoard. handlePromptKey's key >= " " test compares the whole chunk, so a paste whose first character is printable goes in whole: driving a, then the chunk "pasted" + ESC + "[2Jtitle", then Enter, stores pasted<ESC>[2Jtitle. New control-byte rows can therefore still be created after this lands, which makes src/text.ts's own docstring ("the one place free text is cleaned before it is stored") untrue and leaves the boundary half incomplete — the issue's point was that neither half alone is enough. Run each submitted value through cleanText (200 for a card, 60 for a column, 100 for a board) and the three doors say the same thing.
Not blocking: printable's regex is a second copy of CONTROL_BYTES — one concept, so it would sit better exported from src/text.ts and imported by src/tty.ts. And the import.meta.main refactor is a real improvement but nothing in the suite exercises the driver half; keeping the note in the pull that you ran bun run src/tty.ts by hand is worth it (I re-ran it, it works).
Review submitted on #85 as changes requested.
The POST /pulls/85/reviews API returns bad_anchor for every payload, including a minimal one and on other pulls, so the verdict could not be recorded as a review object; it is posted as the pull's review comment instead (two earlier probe comments are noise from finding that out, and the final comment says so).
Findings, verified against a local checkout of 0f4dba5 and the preview:
- Green and real:
bun testpasses 155; removing either half of the fix turns 8 tests red. On the preview, ESC-bearing titles store inert from both the web form and MCPadd_card, control-only titles are refused, accents and emoji survive, and the terminal draws pre-fix rows without their control bytes with columns still aligned. Theimport.meta.mainguard does not breakbun run src/tty.ts. - Blocking:
renderleaves the prompt line unguarded (src/tty.ts:79,src/tty.ts:117).prompt.valueis prefilled from the database bye(src/keys.ts:264) andr(src/keys.ts:298), so pressingeon a pre-fix card emits the raw ESC — the same bug, one keypress away. Reproduced: the frame ends withEdit card: <ESC>[2Jlegacy.state.filterin the header is raw too. - Also blocking:
submitPrompt(src/keys.ts:194-216) writes to the database withoutcleanText, and a pasted chunk beginning with a printable character carries an embedded ESC through, so new dirty rows are still creatable after this lands.
The clone is unchanged and nothing was pushed.
Pushed successfully.
Fixes #34: a card title, board name, or column name carrying control bytes (ESC, BEL, etc.) could ride through the app and get written straight into
draw()'s ANSI stream insrc/tty.ts, letting anyone with a board's link move another person's terminal cursor, clear their screen, or set their window title over ssh.Both halves, as the issue asked for:
cleanTextmoved fromsrc/routes.tsto a newsrc/text.tssosrc/mcp.ts'sadd_cardcan share it instead of its own hand-rolledtrim().slice()— the agent seat can no longer post what the web seat can't. It now strips C0 (\x00-\x1f) and C1 (\x7f-\x9f) control bytes before trimming and slicing, so padding a title with them can't use up its 200 characters and arrive empty-looking, and a title that's only control characters is refused the same as an empty one.src/tty.tsgets aprintable()guard applied to every title and namedraw()interpolates (board names, column names, card titles), since rows written before this fix are already in the database.Refactor for testability:
draw()'s frame-building was pulled into a purerender(state, drawn, db)that returns the text instead of writing to stdout directly, and the module's side-effecting driver code (raw mode, the poll loop, stdin listeners) is now guarded behindif (import.meta.main)so a test can import the module forrender/printablewithout touching the real terminal. The behavior ofbun run src/tty.tsitself is unchanged — verified it still draws correctly when run directly.Tests:
src/text.test.ts(new) coverscleanTextdirectly;src/server.test.tsadds an HTTP test that a card title with\x1bin it stores clean and an MCPadd_cardtest doing the same;src/tty.test.tsadds tests thatrender()prints a card title/board name/column name already sitting in the database (simulating a pre-fix row) without its control bytes, alongside a directprintable()unit test.Left out: the padding-width risk the issue flagged in
visibleLength()resolves itself once titles can no longer contain raw ESC bytes when drawn — no separate change was needed there, just confirmed a column still lines up in the new render test.To try it: open a board's terminal (
FORGE_APP_SSH) and add a card whose title starts with a literal ESC byte from the web form — it now stores and draws clean instead of moving the cursor.Closes #34