diff --git a/src/cli/commands/admin.test.ts b/src/cli/commands/admin.test.ts new file mode 100644 index 0000000..2251a48 --- /dev/null +++ b/src/cli/commands/admin.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { Command } from "commander"; +import { registerAdminCommands } from "./admin.js"; + +describe("registerAdminCommands", () => { + test("registers audited redaction command with live-mutation gates", () => { + const program = new Command(); + registerAdminCommands(program); + + const admin = program.commands.find((command) => command.name() === "admin"); + expect(admin).toBeDefined(); + const redact = admin?.commands.find((command) => command.name() === "redact-messages"); + expect(redact).toBeDefined(); + expect(redact?.options.some((option) => option.long === "--apply")).toBe(true); + expect(redact?.options.some((option) => option.long === "--backup-confirmed")).toBe(true); + expect(redact?.options.some((option) => option.long === "--dry-run-confirmed")).toBe(true); + expect(redact?.options.some((option) => option.long === "--authority")).toBe(true); + }); +}); diff --git a/src/cli/commands/admin.ts b/src/cli/commands/admin.ts new file mode 100644 index 0000000..189681f --- /dev/null +++ b/src/cli/commands/admin.ts @@ -0,0 +1,97 @@ +import type { Command } from "commander"; +import chalk from "chalk"; +import { readFileSync } from "fs"; +import { closeDb } from "../../lib/db.js"; +import { resolveIdentity } from "../../lib/identity.js"; +import { redactMessagesById } from "../../lib/admin-redaction.js"; + +function parseMessageIds(values: string[], idsFile?: string): number[] { + const tokens = [...values]; + if (idsFile) { + tokens.push(...readFileSync(idsFile, "utf8").split(/[\s,]+/)); + } + return tokens + .map((token) => token.trim()) + .filter(Boolean) + .map((token) => { + const id = Number.parseInt(token, 10); + if (!Number.isInteger(id) || id <= 0 || String(id) !== token) { + throw new Error(`Invalid message id: ${token}`); + } + return id; + }); +} + +function printRedactionSummary(result: ReturnType): void { + const mode = result.applied ? chalk.red("APPLIED") : chalk.yellow("DRY RUN"); + console.log(`${mode} message redaction report`); + console.log(chalk.dim(`matched ${result.matched_count}/${result.requested_ids.length}; missing ${result.missing_ids.length}; redacted ${result.redacted_count}`)); + console.log(chalk.dim(`surfaces: ${result.surfaces.join(", ")}`)); + for (const message of result.messages) { + if (!message.exists) { + console.log(chalk.dim(`#${message.id}: missing`)); + continue; + } + const classes = message.secret_classes.length > 0 ? message.secret_classes.join(",") : "unclassified"; + const fields = message.fields.join(","); + const fileSummary = message.attachment_file_count > 0 + ? ` attachment_files=${message.attachment_file_count} deleted=${message.attachment_files_deleted} unsafe=${message.unsafe_attachment_file_count}` + : ""; + console.log(`#${message.id}: ${fields} classes=${classes}${fileSummary}`); + } + if (!result.applied) { + console.log(chalk.dim("No data was changed. Apply requires --apply --backup-confirmed --dry-run-confirmed --authority .")); + } +} + +export function registerAdminCommands(program: Command): void { + const admin = program + .command("admin") + .description("Audited administrative maintenance commands"); + + admin + .command("redact-messages") + .description("Redact known credential-shaped message ids without printing message bodies") + .argument("[ids...]", "Numeric message IDs to redact") + .option("--ids-file ", "Read additional numeric message IDs from a newline/comma-separated file") + .option("--actor ", "Actor performing the review/redaction") + .option("--reason ", "Audit reason for the redaction", "credential-shaped message remediation") + .option("--authority ", "Owner authority reference required with --apply") + .option("--apply", "Apply redaction; default is dry-run only") + .option("--backup-confirmed", "Confirm a current backup exists before live mutation") + .option("--dry-run-confirmed", "Confirm the dry-run report was reviewed before live mutation") + .option("--no-purge-attachments", "Do not delete local attachment files during apply") + .option("-j, --json", "Output as JSON") + .action((ids: string[], opts) => { + try { + const messageIds = parseMessageIds(ids, opts.idsFile); + const actor = resolveIdentity(opts.actor).trim(); + if (!actor) { + console.error(chalk.red("Actor identity is required.")); + process.exit(1); + } + + const result = redactMessagesById({ + ids: messageIds, + actor, + reason: opts.reason, + apply: opts.apply, + authority: opts.authority, + backupConfirmed: opts.backupConfirmed, + dryRunConfirmed: opts.dryRunConfirmed, + purgeAttachments: opts.purgeAttachments, + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printRedactionSummary(result); + } + } catch (error) { + console.error(chalk.red(error instanceof Error ? error.message : String(error))); + process.exit(1); + } finally { + closeDb(); + } + }); +} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 8b46dd1..bbe6b4e 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -14,6 +14,7 @@ import { registerProjectCommands } from "./commands/projects.js"; import { registerAgentCommands } from "./commands/agents.js"; import { registerAnalyticsCommands } from "./commands/analytics.js"; import { registerTmuxCommands } from "./commands/tmux.js"; +import { registerAdminCommands } from "./commands/admin.js"; import pkg from "../../package.json"; const program = new Command(); @@ -30,6 +31,7 @@ registerProjectCommands(program); registerAgentCommands(program); registerAnalyticsCommands(program); registerTmuxCommands(program); +registerAdminCommands(program); // ---- mcp ---- program diff --git a/src/index.ts b/src/index.ts index 8b743ce..50ca85b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,6 +101,17 @@ export { closeDb, } from "./lib/db.js"; +export { + redactMessagesById, +} from "./lib/admin-redaction.js"; + +export type { + RedactMessagesOptions, + RedactMessagesResult, + RedactionClass, + RedactionMessageReport, +} from "./lib/admin-redaction.js"; + export { CANONICAL_CONVERSATIONS_DATABASE_ENV, CANONICAL_CONVERSATIONS_RDS_CLUSTER, diff --git a/src/lib/admin-redaction.test.ts b/src/lib/admin-redaction.test.ts new file mode 100644 index 0000000..b4bfe73 --- /dev/null +++ b/src/lib/admin-redaction.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { closeDb, getDb } from "./db.js"; +import { exportMessages, getMessageById, searchMessages, sendMessage } from "./messages.js"; +import { redactMessagesById } from "./admin-redaction.js"; + +const TEST_ROOT = join(tmpdir(), `conversations-redaction-test-${Date.now()}`); +const TEST_DB = join(TEST_ROOT, "messages.db"); +const ATTACHMENTS_DIR = join(TEST_ROOT, "attachments"); + +beforeEach(() => { + process.env.CONVERSATIONS_DB_PATH = TEST_DB; + process.env.CONVERSATIONS_ATTACHMENTS_DIR = ATTACHMENTS_DIR; + mkdirSync(TEST_ROOT, { recursive: true }); + closeDb(); +}); + +afterEach(() => { + closeDb(); + delete process.env.CONVERSATIONS_DB_PATH; + delete process.env.CONVERSATIONS_ATTACHMENTS_DIR; + try { unlinkSync(TEST_DB); } catch {} + try { unlinkSync(`${TEST_DB}-wal`); } catch {} + try { unlinkSync(`${TEST_DB}-shm`); } catch {} + try { rmSync(TEST_ROOT, { recursive: true, force: true }); } catch {} +}); + +function markerContent(): string { + return ["-----BEGIN", "PRIVATE KEY-----", "placeholder"].join(" "); +} + +function fileContains(path: string, needle: string): boolean { + return existsSync(path) && readFileSync(path).includes(Buffer.from(needle)); +} + +describe("redactMessagesById", () => { + test("dry-run reports ids, fields, classes, and hashes without mutating message data", () => { + const originalContent = markerContent(); + const msg = sendMessage({ + from: "alice", + to: "bob", + content: originalContent, + metadata: { token: "placeholder" }, + }); + + const result = redactMessagesById({ + ids: [msg.id, 999], + actor: "security", + reason: "test review", + }); + + expect(result.dry_run).toBe(true); + expect(result.applied).toBe(false); + expect(result.matched_count).toBe(1); + expect(result.missing_ids).toEqual([999]); + expect(result.messages[0].fields).toContain("content"); + expect(result.messages[0].fields).toContain("metadata"); + expect(result.messages[0].secret_classes).toContain("private_key"); + expect(result.messages[0].secret_classes).toContain("sensitive_metadata_key"); + expect(JSON.stringify(result)).not.toContain("placeholder"); + expect(getMessageById(msg.id)?.content).toBe(originalContent); + }); + + test("apply refuses to run without backup, dry-run, and authority gates", () => { + const msg = sendMessage({ from: "alice", to: "bob", content: markerContent() }); + + expect(() => redactMessagesById({ + ids: [msg.id], + actor: "security", + reason: "test apply", + apply: true, + })).toThrow("backup confirmation"); + + expect(() => redactMessagesById({ + ids: [msg.id], + actor: "security", + reason: "test apply", + apply: true, + backupConfirmed: true, + })).toThrow("dry-run confirmation"); + + expect(() => redactMessagesById({ + ids: [msg.id], + actor: "security", + reason: "test apply", + apply: true, + backupConfirmed: true, + dryRunConfirmed: true, + })).toThrow("owner authority"); + }); + + test("apply redacts content, metadata, attachments, export output, and FTS cache", () => { + const sourceFile = join(TEST_ROOT, "evidence.txt"); + writeFileSync(sourceFile, "placeholder attachment"); + const originalContent = markerContent(); + const msg = sendMessage({ + from: "alice", + to: "bob", + content: originalContent, + metadata: { api_key: "placeholder" }, + attachments: [{ name: "evidence.txt", source_path: sourceFile }], + }); + const originalAttachmentPath = msg.attachments![0].path; + expect(existsSync(originalAttachmentPath)).toBe(true); + + redactMessagesById({ + ids: [msg.id], + actor: "security", + reason: "test redaction", + apply: true, + authority: "ticket-123", + backupConfirmed: true, + dryRunConfirmed: true, + now: "2026-07-06T00:00:00.000Z", + }); + + const updated = getMessageById(msg.id)!; + expect(updated.content).toBe("[REDACTED by conversations admin redaction]"); + expect(updated.metadata?.redacted).toBe(true); + expect(updated.metadata?.original_hashes).toBeDefined(); + expect(updated.attachments?.[0].path).toBe("[redacted]"); + expect(existsSync(originalAttachmentPath)).toBe(false); + expect(exportMessages()).not.toContain("placeholder"); + expect(searchMessages({ query: "placeholder" })).toHaveLength(0); + + const auditRows = getDb().prepare("SELECT * FROM message_redaction_audit WHERE message_id = ?").all(msg.id); + expect(auditRows).toHaveLength(1); + expect(JSON.stringify(auditRows)).not.toContain("placeholder"); + }); + + test("apply scrubs SQLite WAL and free-page storage before reporting success", () => { + const marker = `redaction-marker-${Date.now()}`; + const msg = sendMessage({ from: "alice", to: "bob", content: marker }); + + redactMessagesById({ + ids: [msg.id], + actor: "security", + reason: "test storage scrub", + apply: true, + authority: "ticket-123", + backupConfirmed: true, + dryRunConfirmed: true, + }); + closeDb(); + + for (const path of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`]) { + expect(fileContains(path, marker)).toBe(false); + } + }); + + test("does not delete attachment paths outside the managed attachment directory", () => { + const outsideFile = join(TEST_ROOT, "outside.txt"); + writeFileSync(outsideFile, "placeholder outside"); + const msg = sendMessage({ from: "alice", to: "bob", content: markerContent() }); + getDb().prepare("UPDATE messages SET attachments = ? WHERE id = ?").run(JSON.stringify([{ + name: "outside.txt", + path: outsideFile, + size: readFileSync(outsideFile).byteLength, + mime_type: "text/plain", + }]), msg.id); + + const result = redactMessagesById({ + ids: [msg.id], + actor: "security", + reason: "test unsafe attachment", + apply: true, + authority: "ticket-123", + backupConfirmed: true, + dryRunConfirmed: true, + }); + + expect(existsSync(outsideFile)).toBe(true); + expect(result.messages[0].unsafe_attachment_file_count).toBe(1); + expect(result.messages[0].attachment_files_deleted).toBe(0); + }); +}); diff --git a/src/lib/admin-redaction.ts b/src/lib/admin-redaction.ts new file mode 100644 index 0000000..f848bbb --- /dev/null +++ b/src/lib/admin-redaction.ts @@ -0,0 +1,464 @@ +import { createHash, randomUUID } from "crypto"; +import { existsSync, realpathSync, statSync, unlinkSync } from "fs"; +import { join, resolve, sep } from "path"; +import { getDataDir, getDb } from "./db.js"; + +export type RedactionClass = + | "private_key" + | "cloud_access_key" + | "bearer_token" + | "platform_api_key" + | "package_token" + | "database_url" + | "webhook_url" + | "env_assignment" + | "sensitive_metadata_key" + | "attachment_reference"; + +export interface RedactionMessageReport { + id: number; + exists: boolean; + applied: boolean; + message_uuid: string | null; + channel: string | null; + session_id: string | null; + from_agent: string | null; + to_agent: string | null; + created_at: string | null; + fields: string[]; + secret_classes: RedactionClass[]; + before_hashes: { + content_sha256: string | null; + metadata_sha256: string | null; + attachments_sha256: string | null; + }; + attachment_count: number; + attachment_file_count: number; + attachment_file_path_hashes: string[]; + attachment_files_deleted: number; + attachment_file_delete_errors: number; + unsafe_attachment_file_count: number; + audit_id: string | null; +} + +export interface RedactMessagesOptions { + ids: number[]; + actor: string; + reason: string; + apply?: boolean; + authority?: string; + backupConfirmed?: boolean; + dryRunConfirmed?: boolean; + purgeAttachments?: boolean; + replacementContent?: string; + now?: string; +} + +export interface RedactMessagesResult { + dry_run: boolean; + applied: boolean; + actor: string; + reason: string; + authority: string | null; + backup_confirmed: boolean; + dry_run_confirmed: boolean; + requested_ids: number[]; + matched_count: number; + redacted_count: number; + missing_ids: number[]; + surfaces: string[]; + messages: RedactionMessageReport[]; +} + +type RawMessageRow = { + id: number; + uuid: string | null; + session_id: string; + from_agent: string; + to_agent: string; + channel: string | null; + content: string; + metadata: string | null; + attachments: string | null; + created_at: string; +}; + +type AttachmentReference = { + path_hash: string; + path: string; + exists: boolean; + safe_to_delete: boolean; +}; + +const REDACTION_SURFACES = [ + "messages.content", + "messages.metadata", + "messages.attachments", + "messages_fts", + "export_messages", + "attachment_files", + "sqlite_wal", + "sqlite_free_pages", +]; + +function hashText(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + return createHash("sha256").update(value).digest("hex"); +} + +function uniqueIds(ids: number[]): number[] { + const seen = new Set(); + const result: number[] = []; + for (const id of ids) { + if (!Number.isInteger(id) || id <= 0) { + throw new Error(`Invalid message id: ${id}`); + } + if (seen.has(id)) continue; + seen.add(id); + result.push(id); + } + return result; +} + +function privateKeyRegex(): RegExp { + const begin = "-----BEGIN"; + const suffix = "PRIVATE KEY-----"; + return new RegExp(`${begin}\\s+(?:[A-Z]+\\s+)?${suffix}`, "i"); +} + +function classifyText(value: string | null | undefined, surface: "content" | "metadata" | "attachments"): Set { + const classes = new Set(); + if (!value) return classes; + + if (privateKeyRegex().test(value)) classes.add("private_key"); + if (/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/.test(value)) classes.add("cloud_access_key"); + if (/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/i.test(value)) classes.add("bearer_token"); + if (/\b(?:sk-[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9._-]{20,}|gh[pousr]_[A-Za-z0-9_]{20,})\b/.test(value)) classes.add("platform_api_key"); + if (/\bnpm_[A-Za-z0-9]{20,}\b/.test(value)) classes.add("package_token"); + if (/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/[^\s"']+/i.test(value)) classes.add("database_url"); + if (/\bhttps:\/\/hooks\.[^\s"']+|webhook[_-]?url/i.test(value)) classes.add("webhook_url"); + if (/^[A-Z][A-Z0-9_]{2,}\s*=\s*\S+/m.test(value)) classes.add("env_assignment"); + + if (surface === "metadata" && /"(?:api[_-]?key|token|secret|password|private[_-]?key|database[_-]?url|webhook[_-]?url)"\s*:/i.test(value)) { + classes.add("sensitive_metadata_key"); + } + if (surface === "attachments") { + classes.add("attachment_reference"); + } + + return classes; +} + +function mergeClasses(...sets: Set[]): RedactionClass[] { + return [...new Set(sets.flatMap((set) => [...set]))].sort(); +} + +function parseAttachments(raw: string | null): Array> { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item === "object") as Array> : []; + } catch { + return []; + } +} + +function attachmentsBaseDir(messageId: number): string { + const root = process.env.CONVERSATIONS_ATTACHMENTS_DIR || join(getDataDir(), "attachments"); + return resolve(root, String(messageId)); +} + +function attachmentReferences(row: RawMessageRow): AttachmentReference[] { + const base = `${attachmentsBaseDir(row.id)}${sep}`; + return parseAttachments(row.attachments) + .map((attachment) => typeof attachment.path === "string" ? attachment.path : null) + .filter((path): path is string => Boolean(path)) + .map((path) => { + const resolved = resolve(path); + const pathForSafety = existsSync(resolved) ? realpathSync(resolved) : resolved; + const exists = existsSync(pathForSafety); + const safeToDelete = pathForSafety.startsWith(base) && exists && statSync(pathForSafety).isFile(); + return { + path_hash: hashText(pathForSafety) ?? "", + path: pathForSafety, + exists, + safe_to_delete: safeToDelete, + }; + }); +} + +function reportForRow(row: RawMessageRow, apply: boolean): RedactionMessageReport { + const attachmentRefs = attachmentReferences(row); + const attachmentCount = parseAttachments(row.attachments).length; + const fields = ["content"]; + if (row.metadata !== null) fields.push("metadata"); + if (row.attachments !== null) fields.push("attachments"); + if (attachmentRefs.length > 0) fields.push("attachment_files"); + + return { + id: row.id, + exists: true, + applied: apply, + message_uuid: row.uuid, + channel: row.channel, + session_id: row.session_id, + from_agent: row.from_agent, + to_agent: row.to_agent, + created_at: row.created_at, + fields, + secret_classes: mergeClasses( + classifyText(row.content, "content"), + classifyText(row.metadata, "metadata"), + classifyText(row.attachments, "attachments"), + ), + before_hashes: { + content_sha256: hashText(row.content), + metadata_sha256: hashText(row.metadata), + attachments_sha256: hashText(row.attachments), + }, + attachment_count: attachmentCount, + attachment_file_count: attachmentRefs.filter((ref) => ref.exists).length, + attachment_file_path_hashes: attachmentRefs.map((ref) => ref.path_hash), + attachment_files_deleted: 0, + attachment_file_delete_errors: 0, + unsafe_attachment_file_count: attachmentRefs.filter((ref) => ref.exists && !ref.safe_to_delete).length, + audit_id: null, + }; +} + +function missingReport(id: number): RedactionMessageReport { + return { + id, + exists: false, + applied: false, + message_uuid: null, + channel: null, + session_id: null, + from_agent: null, + to_agent: null, + created_at: null, + fields: [], + secret_classes: [], + before_hashes: { + content_sha256: null, + metadata_sha256: null, + attachments_sha256: null, + }, + attachment_count: 0, + attachment_file_count: 0, + attachment_file_path_hashes: [], + attachment_files_deleted: 0, + attachment_file_delete_errors: 0, + unsafe_attachment_file_count: 0, + audit_id: null, + }; +} + +function ensureRedactionAuditTable(): void { + getDb().exec(` + CREATE TABLE IF NOT EXISTS message_redaction_audit ( + id TEXT PRIMARY KEY, + message_id INTEGER NOT NULL, + message_uuid TEXT, + actor TEXT NOT NULL, + authority TEXT NOT NULL, + reason TEXT NOT NULL, + redacted_at TEXT NOT NULL, + fields TEXT NOT NULL, + secret_classes TEXT NOT NULL, + before_hashes TEXT NOT NULL, + attachment_file_count INTEGER NOT NULL DEFAULT 0, + attachment_file_path_hashes TEXT NOT NULL, + attachment_files_deleted INTEGER NOT NULL DEFAULT 0, + attachment_file_delete_errors INTEGER NOT NULL DEFAULT 0, + unsafe_attachment_file_count INTEGER NOT NULL DEFAULT 0 + ) + `); +} + +function redactedMetadata(report: RedactionMessageReport, opts: Required>, redactedAt: string): string { + return JSON.stringify({ + redacted: true, + redacted_at: redactedAt, + redaction: { + actor: opts.actor, + reason: opts.reason, + authority: opts.authority, + }, + original_hashes: report.before_hashes, + original_fields: report.fields, + secret_classes: report.secret_classes, + }); +} + +function redactedAttachments(report: RedactionMessageReport, redactedAt: string): string | null { + if (report.attachment_count === 0 && !report.before_hashes.attachments_sha256) return null; + return JSON.stringify([{ + name: "redacted-attachments", + path: "[redacted]", + size: 0, + mime_type: "application/x-redacted", + redacted: true, + redacted_at: redactedAt, + original_attachment_count: report.attachment_count, + original_attachments_sha256: report.before_hashes.attachments_sha256, + }]); +} + +function validateApplyGates(opts: RedactMessagesOptions): void { + if (!opts.apply) return; + if (!opts.backupConfirmed) { + throw new Error("Refusing live redaction without backup confirmation."); + } + if (!opts.dryRunConfirmed) { + throw new Error("Refusing live redaction without dry-run confirmation."); + } + if (!opts.authority?.trim()) { + throw new Error("Refusing live redaction without owner authority."); + } + if (!opts.reason?.trim()) { + throw new Error("Refusing live redaction without an audit reason."); + } + if (!opts.actor?.trim()) { + throw new Error("Refusing live redaction without an actor."); + } +} + +function prepareSqliteSecureRedaction(): void { + getDb().exec("PRAGMA secure_delete = ON"); +} + +function scrubSqliteResidualStorage(): void { + const db = getDb(); + db.exec("PRAGMA secure_delete = ON"); + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + db.exec("VACUUM"); + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); +} + +function purgeAttachmentFiles(row: RawMessageRow): { deleted: number; errors: number } { + let deleted = 0; + let errors = 0; + for (const ref of attachmentReferences(row)) { + if (!ref.safe_to_delete) continue; + try { + unlinkSync(ref.path); + deleted++; + } catch { + errors++; + } + } + return { deleted, errors }; +} + +export function redactMessagesById(options: RedactMessagesOptions): RedactMessagesResult { + const ids = uniqueIds(options.ids); + if (ids.length === 0) throw new Error("At least one message id is required."); + + const apply = Boolean(options.apply); + validateApplyGates(options); + + const db = getDb(); + const rows = new Map(); + const select = db.prepare("SELECT id, uuid, session_id, from_agent, to_agent, channel, content, metadata, attachments, created_at FROM messages WHERE id = ?"); + for (const id of ids) { + const row = select.get(id) as RawMessageRow | null; + if (row) rows.set(id, row); + } + + const reports = ids.map((id) => { + const row = rows.get(id); + return row ? reportForRow(row, apply) : missingReport(id); + }); + + if (apply) { + const actor = options.actor.trim(); + const reason = options.reason.trim(); + const authority = options.authority!.trim(); + const redactedAt = options.now ?? new Date().toISOString(); + const replacementContent = options.replacementContent ?? "[REDACTED by conversations admin redaction]"; + + prepareSqliteSecureRedaction(); + ensureRedactionAuditTable(); + db.transaction(() => { + const updateMessage = db.prepare(` + UPDATE messages + SET content = ?, metadata = ?, attachments = ?, edited_at = ? + WHERE id = ? + `); + const insertAudit = db.prepare(` + INSERT INTO message_redaction_audit ( + id, message_id, message_uuid, actor, authority, reason, redacted_at, + fields, secret_classes, before_hashes, attachment_file_count, + attachment_file_path_hashes, attachment_files_deleted, + attachment_file_delete_errors, unsafe_attachment_file_count + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const report of reports) { + if (!report.exists) continue; + const auditId = randomUUID(); + report.audit_id = auditId; + updateMessage.run( + replacementContent, + redactedMetadata(report, { actor, reason, authority }, redactedAt), + redactedAttachments(report, redactedAt), + redactedAt, + report.id, + ); + insertAudit.run( + auditId, + report.id, + report.message_uuid, + actor, + authority, + reason, + redactedAt, + JSON.stringify(report.fields), + JSON.stringify(report.secret_classes), + JSON.stringify(report.before_hashes), + report.attachment_file_count, + JSON.stringify(report.attachment_file_path_hashes), + 0, + 0, + report.unsafe_attachment_file_count, + ); + } + }); + + if (options.purgeAttachments !== false) { + const updateAudit = db.prepare(` + UPDATE message_redaction_audit + SET attachment_files_deleted = ?, attachment_file_delete_errors = ? + WHERE id = ? + `); + for (const report of reports) { + const row = rows.get(report.id); + if (!row || !report.audit_id) continue; + const purge = purgeAttachmentFiles(row); + report.attachment_files_deleted = purge.deleted; + report.attachment_file_delete_errors = purge.errors; + updateAudit.run(purge.deleted, purge.errors, report.audit_id); + } + } + scrubSqliteResidualStorage(); + } + + const matchedCount = reports.filter((report) => report.exists).length; + return { + dry_run: !apply, + applied: apply, + actor: options.actor, + reason: options.reason, + authority: options.authority?.trim() || null, + backup_confirmed: Boolean(options.backupConfirmed), + dry_run_confirmed: Boolean(options.dryRunConfirmed), + requested_ids: ids, + matched_count: matchedCount, + redacted_count: apply ? matchedCount : 0, + missing_ids: reports.filter((report) => !report.exists).map((report) => report.id), + surfaces: REDACTION_SURFACES, + messages: reports, + }; +}