Skip to content
Open
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
19 changes: 19 additions & 0 deletions src/cli/commands/admin.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
97 changes: 97 additions & 0 deletions src/cli/commands/admin.ts
Original file line number Diff line number Diff line change
@@ -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<typeof redactMessagesById>): 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 <ref>."));
}
}

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 <path>", "Read additional numeric message IDs from a newline/comma-separated file")
.option("--actor <agent>", "Actor performing the review/redaction")
.option("--reason <text>", "Audit reason for the redaction", "credential-shaped message remediation")
.option("--authority <ref>", "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();
}
});
}
2 changes: 2 additions & 0 deletions src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -30,6 +31,7 @@ registerProjectCommands(program);
registerAgentCommands(program);
registerAnalyticsCommands(program);
registerTmuxCommands(program);
registerAdminCommands(program);

// ---- mcp ----
program
Expand Down
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
178 changes: 178 additions & 0 deletions src/lib/admin-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading