import { createHash } from "node:crypto"; import { and, eq, inArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { heartbeatRuns } from "@paperclipai/db"; import { REDACTED_EVENT_VALUE } from "../redaction.js"; import { getSecretProvider } from "../secrets/provider-registry.js"; import type { StoredSecretVersionMaterial } from "../secrets/types.js"; import { credentialAccessConnectionNameSql } from "./credential-access-visibility.js"; const REGISTRY_KEY = "paperclipSecretRedactions"; // Project only the registry: run contexts can contain megabytes of prompt data. const registrySnapshot = sql`jsonb_build_object('paperclipSecretRedactions', ${heartbeatRuns.contextSnapshot} -> 'paperclipSecretRedactions')`; type RegistryEntry = { fingerprintSha256: string; material: StoredSecretVersionMaterial; }; function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function registryEntries(contextSnapshot: unknown): RegistryEntry[] { const context = asRecord(contextSnapshot); const raw = context?.[REGISTRY_KEY]; if (!Array.isArray(raw)) return []; return raw.flatMap((value) => { const entry = asRecord(value); const material = asRecord(entry?.material); return typeof entry?.fingerprintSha256 === "string" && material ? [{ fingerprintSha256: entry.fingerprintSha256, material }] : []; }); } function redactText(input: string, values: string[]) { return values.reduce( (result, value) => value.length > 0 ? result.split(value).join(REDACTED_EVENT_VALUE) : result, input, ); } export function redactRegisteredSecretValues(input: T, values: string[], credentialConnectionName: string | null = null): T { if (typeof input === "string") return redactText(input, values) as T; if (Array.isArray(input)) return input.map((value) => redactRegisteredSecretValues(value, values, credentialConnectionName)) as T; // Dates carry no redactable text; rebuilding them via Object.entries would // collapse them to `{}` and break every timestamp in redacted responses. if (input instanceof Date) return input; const record = asRecord(input); if (!record) return input; return Object.fromEntries( Object.entries(record) .filter(([key]) => key !== REGISTRY_KEY && key !== "nativeControllerRecovery" && key !== "nativeTaskSessionPublication") .map(([key, value]) => [key, key === "credentialAccess" ? (credentialConnectionName ? { connectionName: redactText(credentialConnectionName, values) } : {}) : redactRegisteredSecretValues(value, values, credentialConnectionName)]), ) as T; } export function createRunSecretRedactionRegistry(db: Db) { const provider = getSecretProvider("local_encrypted"); async function valuesForRuns(rows: Array<{ contextSnapshot: unknown }>) { const entries = rows.flatMap((row) => registryEntries(row.contextSnapshot)); const unique = new Map(entries.map((entry) => [entry.fingerprintSha256, entry])); const values = await Promise.all( [...unique.values()].map((entry) => provider.resolveVersion({ material: entry.material, externalRef: null, })), ); return values.sort((left, right) => right.length - left.length); } async function valuesForRun(companyId: string, runId: string, viewerUserId: string | null) { const rows = await db.select({ contextSnapshot: registrySnapshot, credentialConnectionName: credentialAccessConnectionNameSql(viewerUserId) }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId))); return { values: await valuesForRuns(rows), credentialConnectionName: rows[0]?.credentialConnectionName ?? null }; } async function valuesForIssue(companyId: string, issueId: string) { const rows = await db.select({ contextSnapshot: registrySnapshot }) .from(heartbeatRuns) .where(and( eq(heartbeatRuns.companyId, companyId), or( sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, sql`${heartbeatRuns.contextSnapshot} -> 'paperclipIssue' ->> 'id' = ${issueId}`, ), )); return valuesForRuns(rows); } return { redactionValuesForRun: async (companyId: string, runId: string) => valuesForRuns(await db .select({ contextSnapshot: registrySnapshot }).from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))), register: async (companyId: string, runId: string, value: string) => { const fingerprintSha256 = createHash("sha256").update(value).digest("hex"); await db.transaction(async (tx) => { const row = await tx.select({ contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId))) .for("update") .then((rows) => rows[0] ?? null); if (!row) throw new Error("Heartbeat run redaction registration failed"); if (registryEntries(row.contextSnapshot).some((entry) => entry.fingerprintSha256 === fingerprintSha256)) { return; } const prepared = await provider.createSecret({ value }); const entry: RegistryEntry = { fingerprintSha256, material: prepared.material }; const contextSnapshot = asRecord(row.contextSnapshot) ?? {}; const currentEntries = Array.isArray(contextSnapshot[REGISTRY_KEY]) ? contextSnapshot[REGISTRY_KEY] : []; await tx.update(heartbeatRuns) .set({ contextSnapshot: { ...contextSnapshot, [REGISTRY_KEY]: [...currentEntries, entry] }, updatedAt: new Date(), }) .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId))); }); }, redactForRuns: async (companyId: string, runs: T[], viewerUserId: string | null = null): Promise => { if (runs.length === 0) return []; const rows = await db.select({ id: heartbeatRuns.id, contextSnapshot: registrySnapshot, credentialConnectionName: credentialAccessConnectionNameSql(viewerUserId) }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runs.map((run) => run.id)))); // Resolve each encrypted value once per request, but apply only each run's // own registry. Do not retain plaintext secrets across requests. const resolved = new Map>(); const valuesByRun = new Map(await Promise.all(rows.map(async (row) => { const values = await Promise.all(registryEntries(row.contextSnapshot).map((entry) => { let value = resolved.get(entry.fingerprintSha256); if (!value) { value = provider.resolveVersion({ material: entry.material, externalRef: null }); resolved.set(entry.fingerprintSha256, value); } return value; })); return [row.id, { values: values.sort((a, b) => b.length - a.length), credentialConnectionName: row.credentialConnectionName ?? null }] as const; }))); return runs.map((run) => { const disclosure = valuesByRun.get(run.id); return redactRegisteredSecretValues(run, disclosure?.values ?? [], disclosure?.credentialConnectionName ?? null); }); }, redactForRun: async (companyId: string, runId: string, value: T, viewerUserId: string | null = null): Promise => { const disclosure = await valuesForRun(companyId, runId, viewerUserId); return redactRegisteredSecretValues(value, disclosure.values, disclosure.credentialConnectionName); }, redactForIssue: async (companyId: string, issueId: string, value: T): Promise => redactRegisteredSecretValues(value, await valuesForIssue(companyId, issueId)), }; }