pike/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files pike/3-events-leaks-every-user-s-drawings-to-ev
| 1 | /** |
| 2 | * The wall every SVG passes, the reply read back, and the palette swap. |
| 3 | * The wall is the one place a model's output meets the page, so it is |
| 4 | * tested by what gets through: nothing that runs, loads or links. |
| 5 | */ |
| 6 | import { expect, test } from "bun:test"; |
| 7 | import { claudeEnv, drawArgs, resultText } from "./claude.ts"; |
| 8 | import { claudeDrawer } from "./draw.ts"; |
| 9 | import { ask, GLOW, INK, inPalette, nameOf, PALETTES, parseReply, sanitize } from "./glyph.ts"; |
| 10 | |
| 11 | const ok = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Hex Chip</title><path d="M12 2 L52 2" stroke="${INK}" stroke-width="4" stroke-linecap="round"/><circle cx="32" cy="32" r="6" fill="${GLOW}"/></svg>`; |
| 12 | |
| 13 | test("a clean SVG comes through as it was", () => { |
| 14 | expect(sanitize(ok)).toBe(ok); |
| 15 | expect(nameOf(ok)).toBe("Hex Chip"); |
| 16 | }); |
| 17 | |
| 18 | test("nothing that runs, loads or links gets through", () => { |
| 19 | const evil = `<svg viewBox="0 0 64 64" onload="alert(1)" style="x"> |
| 20 | <script>alert(1)</script> |
| 21 | <foreignObject><div onclick="x">hi</div></foreignObject> |
| 22 | <image href="https://evil.example/x.png"/> |
| 23 | <a href="javascript:alert(1)"><circle cx="1" cy="1" r="1"/></a> |
| 24 | <use href="https://evil.example/s.svg#x"/> |
| 25 | <style>@import url(https://evil.example)</style> |
| 26 | <path d="M0 0L9 9" fill="url(https://evil.example/#g)" onmouseover="x" stroke="${INK}" transform="translate(2 2)"/> |
| 27 | <rect x="1" y="1" width="5" height="5" transform="expression(alert(1))"/> |
| 28 | <!-- <script>alert(2)</script> --> |
| 29 | <![CDATA[ <script>alert(3)</script> ]]> |
| 30 | </svg>`; |
| 31 | const clean = sanitize(evil)!; |
| 32 | expect(clean).toBeDefined(); |
| 33 | for (const bad of ["script", "onload", "onclick", "onmouseover", "foreignObject", "image", "href", "javascript", "style", "url(", "evil", "expression", "<a", "<use", "alert"]) |
| 34 | expect([bad, clean.includes(bad)]).toEqual([bad, false]); |
| 35 | // What was allowed inside survives: the path, its colour and its transform; the link's child went with it. |
| 36 | expect(clean).toContain(`<path d="M0 0L9 9" stroke="${INK}" transform="translate(2 2)"/>`); |
| 37 | expect(clean).toContain(`<rect x="1" y="1" width="5" height="5"/>`); |
| 38 | expect(clean).not.toContain("<circle"); |
| 39 | }); |
| 40 | |
| 41 | test("a value is a value: no url(#…), no colour functions, only a transform's own", () => { |
| 42 | const svg = sanitize(`<svg viewBox="0 0 8 8"><circle r="1" fill="url(#g)" stroke="rgb(1,2,3)" transform="rotate(45 4 4)"/></svg>`)!; |
| 43 | expect(svg).toContain(`<circle r="1" transform="rotate(45 4 4)"/>`); |
| 44 | }); |
| 45 | |
| 46 | test("a title's text is text, and markup in values is refused", () => { |
| 47 | const svg = sanitize(`<svg viewBox="0 0 8 8"><title>A & B <b>bold</b></title><circle cx="4" cy="4" r="2" fill=""><script>"/></svg>`)!; |
| 48 | // An element inside a title goes with its content; the entity stays an entity. |
| 49 | expect(svg).toContain("<title>A & B </title>"); |
| 50 | expect(sanitize(`<svg viewBox="0 0 8 8"><title>R&D</title><circle r="1"/></svg>`)).toContain("<title>R&D</title>"); |
| 51 | expect(svg).toContain(`<circle cx="4" cy="4" r="2"/>`); |
| 52 | }); |
| 53 | |
| 54 | test("no SVG, an empty one, a second root or a huge one is not kept", () => { |
| 55 | expect(sanitize("just words")).toBeUndefined(); |
| 56 | expect(sanitize(`<svg viewBox="0 0 8 8"><title>Nothing</title></svg>`)).toBeUndefined(); |
| 57 | expect(sanitize(`<svg><circle r="2"/></svg>`)).toBeUndefined(); // no viewBox |
| 58 | expect(sanitize(`<svg viewBox="0 0 8 8"><svg viewBox="0 0 8 8"><circle r="1"/></svg></svg>`)).toBeUndefined(); |
| 59 | const huge = `<svg viewBox="0 0 8 8">${`<circle cx="1" cy="1" r="1"/>`.repeat(2000)}</svg>`; |
| 60 | expect(sanitize(huge)).toBeUndefined(); |
| 61 | // Unclosed tags are closed. |
| 62 | expect(sanitize(`<svg viewBox="0 0 8 8"><g><circle r="1"/>`)).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><g><circle r="1"/></g></svg>`); |
| 63 | }); |
| 64 | |
| 65 | test("the reply's fenced blocks are read, bad ones dropped, at most four kept", () => { |
| 66 | const reply = ["```svg", ok, "```", "```svg", "<svg>nope</svg>", "```", ...Array(5).fill(["```svg", ok, "```"]).flat()].join("\n"); |
| 67 | expect(parseReply(reply)).toEqual([ok, ok, ok, ok]); |
| 68 | // No fences: the bare SVGs in the text. |
| 69 | expect(parseReply(`Here: ${ok} and ${ok}`)).toEqual([ok, ok]); |
| 70 | expect(parseReply("I can't draw that.")).toEqual([]); |
| 71 | }); |
| 72 | |
| 73 | test("a palette swaps ink and glow, even when one palette's ink is another's placeholder", () => { |
| 74 | const neon = inPalette(ok, "neon"); |
| 75 | expect(neon).toContain(`stroke="${PALETTES.neon!.ink}"`); |
| 76 | expect(neon).toContain(`fill="${PALETTES.neon!.glow}"`); |
| 77 | expect(neon).not.toContain(INK); |
| 78 | expect(inPalette(ok, "sage")).toBe(ok); |
| 79 | expect(inPalette(ok.toUpperCase().replace("<SVG", "<svg"), "mono")).toContain(PALETTES.mono!.ink); |
| 80 | }); |
| 81 | |
| 82 | test("the ask names the brief, the kind, the colours and the count", () => { |
| 83 | const text = ask("a fox", "badge"); |
| 84 | for (const part of ["a fox", "badge", INK, GLOW, "4 distinct", "viewBox=\"0 0 64 64\""]) |
| 85 | expect([part, text.includes(part)]).toEqual([part, true]); |
| 86 | }); |
| 87 | |
| 88 | test("Claude is started with no tools, and with nothing of ours but its token and the way out", () => { |
| 89 | const args = drawArgs(); |
| 90 | expect(args.slice(args.indexOf("--tools"), args.indexOf("--tools") + 2)).toEqual(["--tools", ""]); |
| 91 | expect(args).toContain("--no-session-persistence"); |
| 92 | const env = claudeEnv("tok", "/data/claude", { |
| 93 | PATH: "/bin", |
| 94 | HTTPS_PROXY: "http://127.0.0.1:3128", |
| 95 | FORGE_APP_IDENTITY_KEY: "secret", |
| 96 | SOMETHING_ELSE: "x", |
| 97 | }); |
| 98 | expect(env).toMatchObject({ CLAUDE_CODE_OAUTH_TOKEN: "tok", CLAUDE_CONFIG_DIR: "/data/claude", HTTPS_PROXY: "http://127.0.0.1:3128" }); |
| 99 | expect(env.FORGE_APP_IDENTITY_KEY).toBeUndefined(); |
| 100 | expect(env.SOMETHING_ELSE).toBeUndefined(); |
| 101 | }); |
| 102 | |
| 103 | test("Claude's answer is read, and a refusal or junk is a sentence, not a crash", async () => { |
| 104 | const answer = JSON.stringify({ type: "result", is_error: false, result: "```svg\n" + ok + "\n```" }); |
| 105 | expect(resultText(answer)).toEqual({ ok: true, text: "```svg\n" + ok + "\n```" }); |
| 106 | expect(resultText("not json").ok).toBe(false); |
| 107 | expect(resultText(JSON.stringify({ is_error: true, result: "Not logged in" }))).toEqual({ ok: false, why: "Claude stopped: Not logged in" }); |
| 108 | const seen: string[][] = []; |
| 109 | const draw = claudeDrawer(async (args) => { |
| 110 | seen.push([...args]); |
| 111 | return answer; |
| 112 | }); |
| 113 | expect(await draw("a fox", "mark")).toEqual([ok]); |
| 114 | expect(seen[0]).toEqual(drawArgs()); |
| 115 | const empty = claudeDrawer(async () => JSON.stringify({ result: "Sorry, no." })); |
| 116 | await expect(empty("a fox", "mark")).rejects.toThrow("no drawing in it passed"); |
| 117 | }); |