Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/api/migrations/202-redact-base64-thread-parts.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<void> {
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<void> {
// Irreversible — the image bytes are not recoverable.
}
2 changes: 2 additions & 0 deletions apps/api/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -435,6 +436,7 @@ const migrations: Record<string, Migration> = {
migration199dropjiramirrorandorgcolumns,
"200-jira-run-trigger": migration200jirarruntrigger,
"201-organization-notices": migration201organizationnotices,
"202-redact-base64-thread-parts": migration202redactbase64threadparts,
};

export default migrations;
33 changes: 33 additions & 0 deletions apps/api/src/storage/thread-message-parts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<img src="data:image/png;base64,${"A".repeat(400)}">`,
};
const out = JSON.parse(serializePayload(payload));
expect(out.output).toBe('<img src="[base64 data omitted]">');
});
});
39 changes: 35 additions & 4 deletions apps/api/src/storage/thread-message-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading