diff --git a/apps/api/migrations/202-redact-base64-thread-parts.ts b/apps/api/migrations/202-redact-base64-thread-parts.ts new file mode 100644 index 0000000000..c38e83f800 --- /dev/null +++ b/apps/api/migrations/202-redact-base64-thread-parts.ts @@ -0,0 +1,49 @@ +import { type Kysely, sql } from "kysely"; +import { serializePayload } from "../src/storage/thread-message-parts"; + +/** + * Backfill for the redaction added to `serializePayload`: strip the base64 + * image bytes already sitting in `thread_message_parts`. At the time of writing + * that is 700 rows holding 74 MB, and every one of them is folded back into the + * prompt of each later turn on its thread. + * + * The candidate filter is two-stage on purpose. `pg_column_size` reads the + * TOAST pointer without detoasting, so it cuts 1M rows to ~19k for the cost of + * a heap scan; only those get detoasted for the `LIKE`. Payloads are then + * fetched by id in small batches — the matching rows total tens of MB and must + * not land in one result set. + * + * Rewriting is the same `serializePayload` the writer uses, so a row that holds + * nothing to redact serializes byte-identically and is skipped. Re-running + * changes nothing, and there is no `down`: the bytes are gone. + */ +const BATCH = 20; + +export async function up(db: Kysely): Promise { + const candidates = await sql<{ id: string }>` + SELECT id FROM thread_message_parts + WHERE pg_column_size(payload) > 8192 + AND (payload::text LIKE '%"type": "image"%' OR payload::text LIKE '%;base64,%') + `.execute(db); + + for (let i = 0; i < candidates.rows.length; i += BATCH) { + const ids = candidates.rows.slice(i, i + BATCH).map((r) => r.id); + const batch = await sql<{ id: string; payload: unknown }>` + SELECT id, payload FROM thread_message_parts WHERE id = ANY(${ids}) + `.execute(db); + + for (const row of batch.rows) { + const redacted = serializePayload(row.payload); + if (redacted === JSON.stringify(row.payload)) continue; + await sql` + UPDATE thread_message_parts + SET payload = ${redacted}::jsonb + WHERE id = ${row.id} + `.execute(db); + } + } +} + +export async function down(): Promise { + // Irreversible — the image bytes are not recoverable. +} diff --git a/apps/api/migrations/index.ts b/apps/api/migrations/index.ts index 6a7f8c75a5..fe48deda9a 100644 --- a/apps/api/migrations/index.ts +++ b/apps/api/migrations/index.ts @@ -200,6 +200,7 @@ import * as migration198taskboardexternalurl from "./198-task-board-external-url import * as migration199dropjiramirrorandorgcolumns from "./199-drop-jira-mirror-and-org-columns.ts"; import * as migration200jirarruntrigger from "./200-jira-run-trigger.ts"; import * as migration201organizationnotices from "./201-organization-notices.ts"; +import * as migration202redactbase64threadparts from "./202-redact-base64-thread-parts.ts"; /** * Core migrations for the Studio application. @@ -435,6 +436,7 @@ const migrations: Record = { migration199dropjiramirrorandorgcolumns, "200-jira-run-trigger": migration200jirarruntrigger, "201-organization-notices": migration201organizationnotices, + "202-redact-base64-thread-parts": migration202redactbase64threadparts, }; export default migrations; diff --git a/apps/api/src/storage/thread-message-parts.test.ts b/apps/api/src/storage/thread-message-parts.test.ts index b9a41feac1..49731a92c7 100644 --- a/apps/api/src/storage/thread-message-parts.test.ts +++ b/apps/api/src/storage/thread-message-parts.test.ts @@ -122,4 +122,37 @@ describe("serializePayload", () => { expect(serializePayload(payload)).toBe(JSON.stringify(payload)); expect(Date.now() - started).toBeLessThan(2_000); }); + it("drops the bytes of an inline base64 image block", () => { + const payload = { + type: "tool-Read", + state: "output-available", + output: [ + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "iVBORw0KGgo".repeat(5000), + }, + }, + ], + }; + const out = JSON.parse(serializePayload(payload)); + expect(out.output[0]).toEqual({ type: "text", text: "[image omitted]" }); + }); + + it("keeps an image block that only references storage", () => { + const payload = { + output: [{ type: "image", url: "studio-storage://org/thread/shot.png" }], + }; + expect(serializePayload(payload)).toBe(JSON.stringify(payload)); + }); + + it("redacts a base64 data URL embedded in text", () => { + const payload = { + output: ``, + }; + const out = JSON.parse(serializePayload(payload)); + expect(out.output).toBe(''); + }); }); diff --git a/apps/api/src/storage/thread-message-parts.ts b/apps/api/src/storage/thread-message-parts.ts index 04ca9aee44..1f8cc5f49d 100644 --- a/apps/api/src/storage/thread-message-parts.ts +++ b/apps/api/src/storage/thread-message-parts.ts @@ -91,12 +91,40 @@ const URL_USERINFO = /([a-z][a-z0-9+.-]{0,30}:\/\/)[^\s/@]{1,512}@/gi; // would redact chat content the user needs to read. const GITHUB_TOKEN = /\b(gh[pousr]_|github_pat_)[A-Za-z0-9_]{20,}/g; +// A base64 data URL inline in tool output or agent text. Same reasoning as the +// image blocks below, minus the structure: it is bulk binary that nobody reads +// out of this table. Bounded prefix, and the payload class excludes whitespace +// so it cannot run past the URL into surrounding prose. +const BASE64_DATA_URL = + /data:[a-z0-9.+-]{0,60}\/[a-z0-9.+-]{0,60};base64,[A-Za-z0-9+/=]{100,}/gi; + function sanitizeForPg(value: string): string { return value .replace(NUL, "") .replace(LONE_SURROGATE, "\uFFFD") .replace(URL_USERINFO, "$1***@") - .replace(GITHUB_TOKEN, "$1***"); + .replace(GITHUB_TOKEN, "$1***") + .replace(BASE64_DATA_URL, "[base64 data omitted]"); +} + +// A screenshot the agent read is megabytes of base64 that dominates this table +// (54 MB across ~700 rows in production) and rides into every later prompt +// folded from these parts. It becomes a text block, not an image block with +// gutted `data`, so the folded message stays a valid content block. +// +// Only blocks carrying the bytes: an image block pointing at object storage +// (`studio-storage:`, a signed URL) is a cheap reference the UI still needs. +function isInlineBase64Image(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const block = value as { type?: unknown; data?: unknown; source?: unknown }; + if (block.type !== "image") return false; + if (typeof block.data === "string") return true; + const source = block.source; + return ( + typeof source === "object" && + source !== null && + typeof (source as { data?: unknown }).data === "string" + ); } export function serializePayload(payload: unknown): string { @@ -112,9 +140,12 @@ export function serializePayload(payload: unknown): string { // A JSON replacer visits every string in the payload tree; clean strings pass // through untouched (byte-identical output, so ids derived from the payload // stay stable). - return JSON.stringify(payload, (_key, value) => - typeof value === "string" ? sanitizeForPg(value) : value, - ); + return JSON.stringify(payload, (_key, value) => { + if (typeof value === "string") return sanitizeForPg(value); + if (isInlineBase64Image(value)) + return { type: "text", text: "[image omitted]" }; + return value; + }); } export class SqlThreadMessagePartStorage {