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.
scout/tempo:scout/34-a-card-title-cannot-repaint-someone-s-te → main 2 commits fork
7 files
changed +208
−30
src/mcp.ts +3 −2
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import * as bus from "./bus.ts"; |
| 12 | 12 | import * as db from "./db.ts"; |
| 13 | 13 | import { MAX_CARDS_PER_BOARD, writeBucket } from "./limits.ts"; |
| 14 | import { cleanText } from "./text.ts"; | |
| 14 | 15 | import type { Who } from "./who.ts"; |
| 15 | 16 | |
| 16 | 17 | // An agent has no tab of its own, so there is nothing to echo-suppress. |
| @@ -94,8 +95,8 @@ export const tools: readonly Tool[] = [ | ||
| 94 | 95 | if (!board) return fail("not_found", "no such board"); |
| 95 | 96 | const columnId = String(args.column_id ?? ""); |
| 96 | 97 | if (!ENTITY_ID.test(columnId)) return fail("invalid", "column_id looks wrong — call list_columns for this board's column ids"); |
| 97 | const title = String(args.title ?? "").trim().slice(0, 200); | |
| 98 | if (title === "") return fail("invalid", "a card needs a title"); | |
| 98 | const title = cleanText(args.title, 200); | |
| 99 | if (!title) return fail("invalid", "a card needs a title"); | |
| 99 | 100 | if (!writeBucket.take(key)) return fail("rate_limited", "too many changes just now — try again shortly"); |
| 100 | 101 | const card = db.createCard(board.id, columnId, title); |
| 101 | 102 | if (card === "not_found") return fail("not_found", "no such column — call list_columns for this board's column ids"); |
src/routes.ts +1 −4
| @@ -10,6 +10,7 @@ import * as db from "./db.ts"; | ||
| 10 | 10 | import { boardCreationBucket, callerKey, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD, writeBucket } from "./limits.ts"; |
| 11 | 11 | import { rpc } from "./mcp.ts"; |
| 12 | 12 | import { boardPage, landingPage } from "./pages/board.ts"; |
| 13 | import { cleanText } from "./text.ts"; | |
| 13 | 14 | import type { Who } from "./who.ts"; |
| 14 | 15 | |
| 15 | 16 | const BOARD_ID = /^[a-z0-9]{8}$/; |
| @@ -125,10 +126,6 @@ function writeRefused(request: Request, who: Who, boardId: string, key: string): | ||
| 125 | 126 | return boardBad(request, who, boardId, "too many changes just now — try again shortly", 429, { "retry-after": retryAfter }); |
| 126 | 127 | } |
| 127 | 128 | |
| 128 | function cleanText(value: FormDataEntryValue | null, max: number): string | null { | |
| 129 | const text = String(value ?? "").trim().slice(0, max); | |
| 130 | return text === "" ? null : text; | |
| 131 | } | |
| 132 | 129 | |
| 133 | 130 | // A missing content-type is left alone: it parses as an empty form today |
| 134 | 131 | // (a bare `curl -X POST` with no body), and neither the board script nor a |
src/server.test.ts +49
| @@ -221,6 +221,26 @@ test("escapes card titles in the rendered page", async () => { | ||
| 221 | 221 | expect(rendered).toContain("<script>"); |
| 222 | 222 | }); |
| 223 | 223 | |
| 224 | test("strips control bytes from a card title — leaving ordinary characters untouched — and refuses a title that is only control characters, the same as an empty one", async () => { | |
| 225 | // db.createBoard, not POST /boards: the board-creation bucket is shared, | |
| 226 | // process-wide state (see limits.ts and the no-JS column-limit test | |
| 227 | // above) — spending a token here would just narrow the margin for the | |
| 228 | // tests that actually exercise board creation. | |
| 229 | const id = db.createBoard("Control-byte board").id; | |
| 230 | const boardUrl = `/b/${id}`; | |
| 231 | const [todo] = db.listColumns(id); | |
| 232 | ||
| 233 | const created = await post(`${boardUrl}/columns/${todo.id}/cards`, { title: "\x1bclear the café 🎉\x07" }); | |
| 234 | const { id: cardId } = (await created.json()) as { id: string }; | |
| 235 | const [card] = db.listCards(id).filter((c) => c.id === cardId); | |
| 236 | expect(card.title).toBe("clear the café 🎉"); | |
| 237 | expect(card.title).not.toContain("\x1b"); | |
| 238 | expect(card.title).not.toContain("\x07"); | |
| 239 | ||
| 240 | const refused = await post(`${boardUrl}/columns/${todo.id}/cards`, { title: "\x1b\x07\x00" }); | |
| 241 | expect(refused.status).toBe(400); | |
| 242 | }); | |
| 243 | ||
| 224 | 244 | test("cannot move a card into another board's column", async () => { |
| 225 | 245 | const a = await newBoard(); |
| 226 | 246 | const b = await newBoard(); |
| @@ -534,6 +554,35 @@ test("the MCP server lists its tools and calls add_card as JSON-RPC", async () = | ||
| 534 | 554 | expect(missing.error.code).toBe(-32602); |
| 535 | 555 | }); |
| 536 | 556 | |
| 557 | test("add_card strips control bytes from a title the same way the web form does", async () => { | |
| 558 | const id = db.createBoard("MCP control-byte board").id; // see the note above on why not POST /boards | |
| 559 | const [todo] = db.listColumns(id); | |
| 560 | const rpcCall = (body: unknown) => | |
| 561 | fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 562 | ||
| 563 | const added = await ( | |
| 564 | await rpcCall({ | |
| 565 | jsonrpc: "2.0", | |
| 566 | id: 1, | |
| 567 | method: "tools/call", | |
| 568 | params: { name: "add_card", arguments: { board_id: id, column_id: todo.id, title: "\x1bfrom an agent" } }, | |
| 569 | }) | |
| 570 | ).json(); | |
| 571 | expect(added.result.content[0].text).not.toContain("\x1b"); | |
| 572 | const [card] = db.listCards(id).filter((c) => c.column_id === todo.id); | |
| 573 | expect(card.title).toBe("from an agent"); | |
| 574 | ||
| 575 | const refused = await ( | |
| 576 | await rpcCall({ | |
| 577 | jsonrpc: "2.0", | |
| 578 | id: 2, | |
| 579 | method: "tools/call", | |
| 580 | params: { name: "add_card", arguments: { board_id: id, column_id: todo.id, title: "\x1b\x07\x00" } }, | |
| 581 | }) | |
| 582 | ).json(); | |
| 583 | expect(refused.result.isError).toBe(true); | |
| 584 | }); | |
| 585 | ||
| 537 | 586 | test("list_columns on a fresh board returns the three default columns with ids", async () => { |
| 538 | 587 | const id = db.createBoard("Fresh board").id; // see the note above on why not POST /boards |
| 539 | 588 |
src/text.test.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { cleanText } from "./text.ts"; | |
| 3 | ||
| 4 | test("cleanText strips C0 and C1 control bytes before trimming and slicing", () => { | |
| 5 | // The ESC and BEL bytes themselves are what a terminal acts on; stripping | |
| 6 | // them leaves the rest of the sequence (`[2J`) as inert printable text. | |
| 7 | expect(cleanText("\x1btitle\x07", 200)).toBe("title"); | |
| 8 | expect(cleanText("\x1b[2Jtitle\x07", 200)).toBe("[2Jtitle"); | |
| 9 | expect(cleanText("plain title", 200)).toBe("plain title"); | |
| 10 | }); | |
| 11 | ||
| 12 | test("cleanText leaves ordinary characters — accents, emoji, punctuation — untouched", () => { | |
| 13 | expect(cleanText("café — déjà vu 🎉!", 200)).toBe("café — déjà vu 🎉!"); | |
| 14 | }); | |
| 15 | ||
| 16 | test("cleanText refuses a title that is only control characters, the same as an empty one", () => { | |
| 17 | expect(cleanText("\x1b\x07\x00", 200)).toBeNull(); | |
| 18 | expect(cleanText(" ", 200)).toBeNull(); | |
| 19 | expect(cleanText("", 200)).toBeNull(); | |
| 20 | }); | |
| 21 | ||
| 22 | test("control bytes are stripped before the length slice, so padding with them cannot use up a title's length", () => { | |
| 23 | const padded = "\x1b".repeat(200) + "real title"; | |
| 24 | expect(cleanText(padded, 200)).toBe("real title"); | |
| 25 | }); | |
| 26 | ||
| 27 | test("cleanText trims and slices what remains after stripping, same as before this fix", () => { | |
| 28 | expect(cleanText(" spaced ", 200)).toBe("spaced"); | |
| 29 | expect(cleanText("a".repeat(210), 200)).toBe("a".repeat(200)); | |
| 30 | }); |
src/text.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | /** | |
| 2 | * The one place free text (a board name, a column name, a card title) is | |
| 3 | * cleaned before it is stored — used by the web forms (src/routes.ts) and | |
| 4 | * the MCP tools (src/mcp.ts) alike, so an agent cannot post what a browser | |
| 5 | * cannot. | |
| 6 | */ | |
| 7 | ||
| 8 | // C0 (\x00-\x1f) and C1 (\x7f-\x9f) control bytes: none of them belong in a | |
| 9 | // one-line title, and left in they can move a terminal's cursor or set its | |
| 10 | // window title (src/tty.ts draws these straight into an ANSI stream). | |
| 11 | // Stripped before the slice below, so padding a title with them cannot use | |
| 12 | // up its length and leave it arriving empty-looking. | |
| 13 | const CONTROL_BYTES = /[\x00-\x1f\x7f-\x9f]/g; | |
| 14 | ||
| 15 | export function cleanText(value: unknown, max: number): string | null { | |
| 16 | const text = String(value ?? "") | |
| 17 | .replace(CONTROL_BYTES, "") | |
| 18 | .trim() | |
| 19 | .slice(0, max); | |
| 20 | return text === "" ? null : text; | |
| 21 | } |
src/tty.test.ts +46
| @@ -17,6 +17,10 @@ const db = await import("./db.ts"); | ||
| 17 | 17 | const { emptyDrawn, follow, handleKey, initialState, keepCursor, shouldRedraw, visibleBoards, visibleCards } = await import( |
| 18 | 18 | "./keys.ts" |
| 19 | 19 | ); |
| 20 | // Imported after FORGE_APP_DATA is set, same as db.ts above; the module's | |
| 21 | // own driver code (raw mode, the poll loop) only runs under `import.meta.main` | |
| 22 | // (true for `bun run src/tty.ts`, false here), so importing it costs nothing. | |
| 23 | const { printable, render } = await import("./tty.ts"); | |
| 20 | 24 | |
| 21 | 25 | /** One keypress after another, the way the tty feeds them in. */ |
| 22 | 26 | function press(state: State, ...keys: string[]): State { |
| @@ -601,3 +605,45 @@ test("follow redraws the boards list and carries its cursor onto the same board | ||
| 601 | 605 | const boards = visibleBoards(db, owner.id); |
| 602 | 606 | expect(boards[result.state.boardsCursor].id).toBe(keptId); |
| 603 | 607 | }); |
| 608 | ||
| 609 | // printable and render are the fix for #34: a title or name can carry | |
| 610 | // control bytes from before this landed (cleanText, src/text.ts, keeps new | |
| 611 | // ones out), and draw() must not write them into the terminal's ANSI | |
| 612 | // stream regardless of where they came from. | |
| 613 | ||
| 614 | test("printable strips C0 and C1 control bytes but leaves ordinary text, including non-ASCII, alone", () => { | |
| 615 | // What acts on a terminal is the ESC/BEL byte itself, not the printable | |
| 616 | // characters that can follow it — stripping the byte leaves the rest | |
| 617 | // inert, unable to address the terminal on its own. | |
| 618 | expect(printable("\x1bclear the café 🎉\x07")).toBe("clear the café 🎉"); | |
| 619 | expect(printable("plain title")).toBe("plain title"); | |
| 620 | expect(printable("\x1b\x07\x00")).toBe(""); | |
| 621 | }); | |
| 622 | ||
| 623 | test("render prints a card title already sitting in the database — from before this fix — without its control bytes", () => { | |
| 624 | const { state, boardId } = openBoard(); | |
| 625 | const [todo] = db.listColumns(boardId); | |
| 626 | // db.createCard takes a title as-is; the boundary that would reject or | |
| 627 | // clean this (cleanText, src/text.ts) is in src/routes.ts and src/mcp.ts, | |
| 628 | // not here — this simulates a row that predates the fix. | |
| 629 | db.createCard(boardId, todo.id, "\x1bclear-screen malicious\x07"); | |
| 630 | ||
| 631 | const { text, drawn: after } = render(state, emptyDrawn, db); | |
| 632 | expect(text).not.toContain("\x1b" + "clear-screen"); // the ESC byte itself is gone | |
| 633 | expect(text).not.toContain("\x07"); | |
| 634 | expect(text).toContain("clear-screen malicious"); | |
| 635 | expect(after.cards.map((c) => c.title)).toEqual(["\x1bclear-screen malicious\x07"]); // the row itself is untouched | |
| 636 | }); | |
| 637 | ||
| 638 | test("render prints a board name and a column name without their control bytes too", () => { | |
| 639 | const board = db.createBoard("\x1bpwned\x07Board"); | |
| 640 | const column = db.listColumns(board.id)[0]; | |
| 641 | db.renameColumn(board.id, column.id, "\x1bColumn"); | |
| 642 | const state = { ...initialState(), mode: "board" as const, boardId: board.id }; | |
| 643 | ||
| 644 | const { text } = render(state, emptyDrawn, db); | |
| 645 | expect(text).not.toContain("\x1bpwned\x07"); | |
| 646 | expect(text).toContain("pwnedBoard"); | |
| 647 | expect(text).toContain("Column"); | |
| 648 | expect(text).not.toContain("\x1bColumn"); | |
| 649 | }); |
src/tty.ts +58 −24
| @@ -10,7 +10,17 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import * as db from "./db.ts"; |
| 13 | import { emptyDrawn, follow, handleKey, initialState, visibleBoards, visibleCards, type Drawn, type State } from "./keys.ts"; | |
| 13 | import { | |
| 14 | emptyDrawn, | |
| 15 | follow, | |
| 16 | handleKey, | |
| 17 | initialState, | |
| 18 | visibleBoards, | |
| 19 | visibleCards, | |
| 20 | type BoardStore, | |
| 21 | type Drawn, | |
| 22 | type State, | |
| 23 | } from "./keys.ts"; | |
| 14 | 24 | import { ownerIdFromEnv } from "./session.ts"; |
| 15 | 25 | |
| 16 | 26 | const esc = "\x1b["; |
| @@ -25,7 +35,16 @@ let state: State = initialState(ownerIdFromEnv(process.env)); | ||
| 25 | 35 | // else's change rather than a keypress. |
| 26 | 36 | let drawn: Drawn = emptyDrawn; |
| 27 | 37 | |
| 28 | function draw(): void { | |
| 38 | // A title or name reaching here came through cleanText (src/text.ts) if it | |
| 39 | // was written since that landed, but the database already held whatever was | |
| 40 | // written before — this is the other half of the fix, guarding the draw | |
| 41 | // itself rather than trusting everything stored is already clean. | |
| 42 | export function printable(s: string): string { | |
| 43 | return s.replace(/[\x00-\x1f\x7f-\x9f]/g, ""); | |
| 44 | } | |
| 45 | ||
| 46 | /** The frame for the current state, as plain data — no stdout, no clock. */ | |
| 47 | export function render(state: State, drawnBefore: Drawn, db: BoardStore): { text: string; drawn: Drawn; revision: number } { | |
| 29 | 48 | // Read before the lists below, not after: a write landing while this |
| 30 | 49 | // function runs would otherwise be recorded as already-seen (the |
| 31 | 50 | // revision read at the end would include it) without its effect ever |
| @@ -33,6 +52,7 @@ function draw(): void { | ||
| 33 | 52 | // write. Reading first costs at most one redundant redraw instead. |
| 34 | 53 | const revision = state.mode === "boards" ? db.boardsRevision() : db.boardRevision(state.boardId!); |
| 35 | 54 | |
| 55 | let drawn = drawnBefore; | |
| 36 | 56 | const lines: string[] = []; |
| 37 | 57 | const prompt = state.prompt; |
| 38 | 58 | if (state.mode === "boards") { |
| @@ -48,8 +68,9 @@ function draw(): void { | ||
| 48 | 68 | ); |
| 49 | 69 | } |
| 50 | 70 | boards.forEach((b, i) => { |
| 71 | const title = printable(b.name); | |
| 51 | 72 | const mark = i === state.boardsCursor ? "▸" : " "; |
| 52 | const name = i === state.boardsCursor ? `${esc}1m${b.name}${esc}0m` : b.name; | |
| 73 | const name = i === state.boardsCursor ? `${esc}1m${title}${esc}0m` : title; | |
| 53 | 74 | lines.push(`${mark} ${name} ${esc}2m/b/${b.id}${esc}0m`); |
| 54 | 75 | }); |
| 55 | 76 | lines.push(""); |
| @@ -63,17 +84,19 @@ function draw(): void { | ||
| 63 | 84 | const columns = db.listColumns(state.boardId!); |
| 64 | 85 | drawn = { ...drawn, columns }; |
| 65 | 86 | lines.push( |
| 66 | `${esc}1mtempo${esc}0m ${esc}2m${board.name} /b/${board.id}${esc}0m${state.filter ? ` ${esc}2mfilter: ${state.filter}${esc}0m` : ""}`, | |
| 87 | `${esc}1mtempo${esc}0m ${esc}2m${printable(board.name)} /b/${board.id}${esc}0m${state.filter ? ` ${esc}2mfilter: ${state.filter}${esc}0m` : ""}`, | |
| 67 | 88 | ); |
| 68 | 89 | lines.push(""); |
| 69 | 90 | const columnLines = columns.map((col, ci) => { |
| 70 | 91 | const cards = visibleCards(db, state, col.id); |
| 71 | 92 | if (ci === state.columnCursor) drawn = { ...drawn, cards }; |
| 72 | const header = ci === state.columnCursor ? `${esc}1m${col.name}${esc}0m` : col.name; | |
| 93 | const colName = printable(col.name); | |
| 94 | const header = ci === state.columnCursor ? `${esc}1m${colName}${esc}0m` : colName; | |
| 73 | 95 | const body = cards.map((c, i) => { |
| 74 | 96 | const focused = ci === state.columnCursor && i === state.cardCursor; |
| 75 | 97 | const mark = focused ? "▸" : " "; |
| 76 | return `${mark} ${focused ? `${esc}1m${c.title}${esc}0m` : c.title}`; | |
| 98 | const title = printable(c.title); | |
| 99 | return `${mark} ${focused ? `${esc}1m${title}${esc}0m` : title}`; | |
| 77 | 100 | }); |
| 78 | 101 | if (body.length === 0) body.push(`${esc}2m(empty)${esc}0m`); |
| 79 | 102 | return [`${header}`, ...body]; |
| @@ -95,8 +118,14 @@ function draw(): void { | ||
| 95 | 118 | : state.message || `${esc}2mhjkl move · a add · e edit · d delete · H/L/K/J move card · c column · r rename · D delete col · [ ] reorder col · f filter · u undo · b back · q quit${esc}0m`, |
| 96 | 119 | ); |
| 97 | 120 | } |
| 98 | state.revision = revision; | |
| 99 | out(`${esc}2J${esc}H` + lines.join("\r\n")); | |
| 121 | return { text: `${esc}2J${esc}H` + lines.join("\r\n"), drawn, revision }; | |
| 122 | } | |
| 123 | ||
| 124 | function draw(): void { | |
| 125 | const result = render(state, drawn, db); | |
| 126 | drawn = result.drawn; | |
| 127 | state.revision = result.revision; | |
| 128 | out(result.text); | |
| 100 | 129 | } |
| 101 | 130 | |
| 102 | 131 | // ANSI codes have zero visible width; padEnd must not count them. |
| @@ -116,20 +145,25 @@ function poll(): void { | ||
| 116 | 145 | draw(); |
| 117 | 146 | } |
| 118 | 147 | |
| 119 | process.stdin.setRawMode?.(true); | |
| 120 | process.stdin.resume(); | |
| 121 | process.stdin.setEncoding("utf8"); | |
| 122 | draw(); | |
| 123 | const timer = setInterval(poll, POLL_MS); | |
| 124 | timer.unref?.(); | |
| 125 | process.stdin.on("data", (key: string) => { | |
| 126 | const outcome = handleKey(state, key, db); | |
| 127 | if (outcome.quit) { | |
| 128 | clearInterval(timer); | |
| 129 | out(`${esc}2J${esc}H`); | |
| 130 | process.exit(0); | |
| 131 | } | |
| 132 | state = outcome.state; | |
| 148 | // Guarded so a test can import this module for `render`/`printable` without | |
| 149 | // putting the real stdin in raw mode or starting the poll loop — the only | |
| 150 | // thing that should run this half is `bun run src/tty.ts` itself. | |
| 151 | if (import.meta.main) { | |
| 152 | process.stdin.setRawMode?.(true); | |
| 153 | process.stdin.resume(); | |
| 154 | process.stdin.setEncoding("utf8"); | |
| 133 | 155 | draw(); |
| 134 | }); | |
| 135 | process.stdout.on("resize", draw); | |
| 156 | const timer = setInterval(poll, POLL_MS); | |
| 157 | timer.unref?.(); | |
| 158 | process.stdin.on("data", (key: string) => { | |
| 159 | const outcome = handleKey(state, key, db); | |
| 160 | if (outcome.quit) { | |
| 161 | clearInterval(timer); | |
| 162 | out(`${esc}2J${esc}H`); | |
| 163 | process.exit(0); | |
| 164 | } | |
| 165 | state = outcome.state; | |
| 166 | draw(); | |
| 167 | }); | |
| 168 | process.stdout.on("resize", draw); | |
| 169 | } |
new above
offline