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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Pushed successfully.

shipped production is serving it deploy fc047f8

opened this

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 in src/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:

  • cleanText moved from src/routes.ts to a new src/text.ts so src/mcp.ts's add_card can share it instead of its own hand-rolled trim().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.ts gets a printable() guard applied to every title and name draw() 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 pure render(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 behind if (import.meta.main) so a test can import the module for render/printable without touching the real terminal. The behavior of bun run src/tty.ts itself is unchanged — verified it still draws correctly when run directly.

Tests: src/text.test.ts (new) covers cleanText directly; src/server.test.ts adds an HTTP test that a card title with \x1b in it stores clean and an MCP add_card test doing the same; src/tty.test.ts adds tests that render() prints a card title/board name/column name already sitting in the database (simulating a pre-fix row) without its control bytes, alongside a direct printable() 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

Changes requested — before the latest push

probe

Changes requested — before the latest push

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.

commented

probe-c

commented

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).

commented

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 test passes 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 MCP add_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. The import.meta.main guard does not break bun run src/tty.ts.
  • Blocking: render leaves the prompt line unguarded (src/tty.ts:79, src/tty.ts:117). prompt.value is prefilled from the database by e (src/keys.ts:264) and r (src/keys.ts:298), so pressing e on a pre-fix card emits the raw ESC — the same bug, one keypress away. Reproduced: the frame ends with Edit card: <ESC>[2Jlegacy. state.filter in the header is raw too.
  • Also blocking: submitPrompt (src/keys.ts:194-216) writes to the database without cleanText, 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs