From e78e9a8aa06fb282cd61c5752160b949f05fd903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 23 May 2026 21:59:16 -0400 Subject: [PATCH] Audit chat message mutations --- AgentCompany-New/src/main/app/ipc-handlers.ts | 2 + .../services/chat-message-mutation-service.ts | 133 ++++++++++++++++++ .../src/main/services/chat-service.ts | 13 +- AgentCompany-New/src/preload/index.ts | 2 + AgentCompany-New/src/shared/commands.ts | 15 ++ AgentCompany-New/src/shared/desktop-api.ts | 4 + AgentCompany-New/src/shared/ipc.ts | 2 + .../tests/unit/chat-governance.test.ts | 62 ++++++++ 8 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 AgentCompany-New/src/main/services/chat-message-mutation-service.ts diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index af10edac..e9c2cc9f 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -104,6 +104,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.grantRole, (_event, input) => mutate(() => identityAccess.grantRole(input))); ipcMain.handle(IPC_CHANNELS.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input))); ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input))); + ipcMain.handle(IPC_CHANNELS.editMessage, (_event, input) => mutate(() => chat.editMessage(input))); + ipcMain.handle(IPC_CHANNELS.deleteMessage, (_event, input) => mutate(() => chat.deleteMessage(input))); ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input))); ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input))); ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, (_event, input) => mutate(() => providers.revokeCredential(input))); diff --git a/AgentCompany-New/src/main/services/chat-message-mutation-service.ts b/AgentCompany-New/src/main/services/chat-message-mutation-service.ts new file mode 100644 index 00000000..377528f3 --- /dev/null +++ b/AgentCompany-New/src/main/services/chat-message-mutation-service.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { DeleteMessageInput, EditMessageInput, SendMessageInput } from "../../shared/commands"; +import { buildChatMessageStructure } from "./chat-message-structure"; +import { recordChatMentions, resolveChatMentions } from "./chat-mentions"; +import { AuditService } from "./audit-service"; + +interface MessageMutationRow { + id: string; + org_id: string; + space_id: string; + sender_type: SendMessageInput["senderType"]; + sender_id: string | null; + body_md: string; + deleted_at: string | null; +} + +interface PrincipalRef { + type: "employee" | "agent"; + id: string; +} + +export class ChatMessageMutationService { + private readonly audit: AuditService; + + constructor(private readonly db: DatabaseSync) { + this.audit = new AuditService(db); + } + + editMessage(input: EditMessageInput): void { + const body = input.bodyMd.trim(); + if (!body) throw new Error("Message body is required."); + if (parseSlashInvocation(body)) throw new Error("Slash command messages are immutable."); + const message = this.requireMutableMessage(input.orgId, input.messageId); + if (parseSlashInvocation(message.body_md)) throw new Error("Slash command messages are immutable."); + this.assertMessageMutationActor(input, message); + const attachmentFileIds = this.messageAttachmentFileIds(message.id); + const mentions = resolveChatMentions(this.db, input.orgId, message.space_id, body); + const structuredBody = buildChatMessageStructure({ slash: null, mentions, attachmentFileIds }); + const now = new Date().toISOString(); + this.db.prepare(` + update chat_messages + set body_md = ?, body_structured_json = ?, edited_at = ? + where id = ? and org_id = ? and deleted_at is null + `).run(body, structuredBody, now, message.id, input.orgId); + this.db.prepare("delete from chat_mentions where message_id = ?").run(message.id); + recordChatMentions(this.db, message.id, mentions); + this.audit.record({ + orgId: input.orgId, + entityType: "chat_message", + entityId: message.id, + actorType: input.actorType, + actorId: input.actorId, + action: "edited", + payload: { editedAt: now, previousBodySha256: sha256(message.body_md), bodySha256: sha256(body) }, + }); + } + + deleteMessage(input: DeleteMessageInput): void { + const message = this.requireMutableMessage(input.orgId, input.messageId); + this.assertMessageMutationActor(input, message); + const now = new Date().toISOString(); + this.db.prepare("update chat_messages set deleted_at = ? where id = ? and org_id = ? and deleted_at is null") + .run(now, message.id, input.orgId); + this.audit.record({ + orgId: input.orgId, + entityType: "chat_message", + entityId: message.id, + actorType: input.actorType, + actorId: input.actorId, + action: "deleted", + payload: { deletedAt: now, bodySha256: sha256(message.body_md) }, + }); + } + + private requireMutableMessage(orgId: string, messageId: string): MessageMutationRow { + const message = this.db.prepare(` + select id, org_id, space_id, sender_type, sender_id, body_md, deleted_at + from chat_messages + where id = ? and org_id = ? + `).get(messageId, orgId) as MessageMutationRow | undefined; + if (!message || message.deleted_at) throw new Error("Mutable chat message was not found."); + return message; + } + + private assertMessageMutationActor(input: EditMessageInput | DeleteMessageInput, message: MessageMutationRow): void { + const actor = this.requireSender(input.orgId, input.actorType, input.actorId); + if (actor) this.requireMembership(message.space_id, actor); + if (input.actorType === "system") { + if (message.sender_type === "system") return; + throw new Error("Message actor is not allowed to mutate this message."); + } + if (input.actorType === "board") return; + if (message.sender_type === input.actorType && message.sender_id === input.actorId) return; + throw new Error("Message actor is not allowed to mutate this message."); + } + + private requireSender(orgId: string, senderType: SendMessageInput["senderType"], senderId: string | null): PrincipalRef | null { + if (senderType === "system") return null; + if (senderType === "employee") return this.requirePrincipal("employees", orgId, senderId, "Employee actor"); + if (senderType === "agent") return this.requirePrincipal("agents", orgId, senderId, "Agent actor"); + return this.requirePrincipal("employees", orgId, senderId, "Board actor"); + } + + private requirePrincipal(table: "employees" | "agents", orgId: string, id: string | null, label: string): PrincipalRef { + if (!id) throw new Error(`${label} is required.`); + const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId) as { id: string } | undefined; + if (!row) throw new Error(`${label} was not found.`); + return { type: table === "employees" ? "employee" : "agent", id }; + } + + private requireMembership(spaceId: string, principal: PrincipalRef): void { + const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = ? and principal_id = ?") + .get(spaceId, principal.type, principal.id) as { space_id: string } | undefined; + if (!row) throw new Error("Chat membership is required."); + } + + private messageAttachmentFileIds(messageId: string): string[] { + const rows = this.db.prepare("select file_id from chat_attachments where message_id = ? order by file_id asc") + .all(messageId) as Array<{ file_id: string }>; + return rows.map((row) => row.file_id); + } +} + +function parseSlashInvocation(body: string): { command: string; args: string } | null { + const match = body.match(/^\/([a-z][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i); + if (!match) return null; + return { command: match[1].toLowerCase(), args: match[2] ?? "" }; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 737364af..2a7d0822 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -1,5 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; -import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands"; +import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands"; import { CHAT_THREAD_BOARD_STATE, FILE_SCAN_STATE, @@ -11,6 +11,7 @@ import { ApprovalService } from "./approval-service"; import { AuditService } from "./audit-service"; import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service"; import { buildChatMessageStructure } from "./chat-message-structure"; +import { ChatMessageMutationService } from "./chat-message-mutation-service"; import { recordChatMentions, resolveChatMentions, type ResolvedMention } from "./chat-mentions"; import { FileHandoffService, type FileHandoffAttachment } from "./file-handoff-service"; @@ -52,12 +53,14 @@ export class ChatService { private readonly approvals: ApprovalService; private readonly boardInterventions: BoardInterventionService; private readonly handoffs: FileHandoffService; + private readonly messageMutations: ChatMessageMutationService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.approvals = new ApprovalService(db); this.boardInterventions = new BoardInterventionService(db); this.handoffs = new FileHandoffService(db); + this.messageMutations = new ChatMessageMutationService(db); } createSpace(input: CreateChatSpaceInput): string { @@ -197,6 +200,14 @@ export class ChatService { `).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now); } + editMessage(input: EditMessageInput): void { + this.messageMutations.editMessage(input); + } + + deleteMessage(input: DeleteMessageInput): void { + this.messageMutations.deleteMessage(input); + } + private executeSlashCommand( input: SendMessageInput, space: SpaceRow, diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index c30d04a1..0676cf5c 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -22,6 +22,8 @@ const api: DesktopApi = { grantRole: (input) => invoke(IPC_CHANNELS.grantRole, input), createChatSpace: (input) => invoke(IPC_CHANNELS.createChatSpace, input), sendMessage: (input) => invoke(IPC_CHANNELS.sendMessage, input), + editMessage: (input) => invoke(IPC_CHANNELS.editMessage, input), + deleteMessage: (input) => invoke(IPC_CHANNELS.deleteMessage, input), markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input), saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input), diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 470649eb..eaa325b5 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -123,6 +123,21 @@ export interface SendMessageInput { attachmentFileIds?: string[]; } +export interface EditMessageInput { + orgId: string; + messageId: string; + actorType: SendMessageInput["senderType"]; + actorId: string | null; + bodyMd: string; +} + +export interface DeleteMessageInput { + orgId: string; + messageId: string; + actorType: SendMessageInput["senderType"]; + actorId: string | null; +} + export interface CreateChatSpaceInput { orgId: string; spaceType: "company" | "department" | "project" | "direct"; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 40f04be6..9c94bea7 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -22,7 +22,9 @@ import type { CreateSocialAccountInput, CreateTaskInput, DecideApprovalInput, + DeleteMessageInput, DeleteOrganizationInput, + EditMessageInput, ImportFileInput, ImportSkillDirectoryInput, GrantRoleInput, @@ -72,6 +74,8 @@ export interface DesktopApi { grantRole(input: GrantRoleInput): Promise>; createChatSpace(input: CreateChatSpaceInput): Promise>; sendMessage(input: SendMessageInput): Promise>; + editMessage(input: EditMessageInput): Promise>; + deleteMessage(input: DeleteMessageInput): Promise>; markChatSpaceRead(input: MarkChatSpaceReadInput): Promise>; saveProviderCredential(input: SaveProviderCredentialInput): Promise>; revokeProviderCredential(input: RevokeProviderCredentialInput): Promise>; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 9a1bc278..4bc15ae8 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -14,6 +14,8 @@ export const IPC_CHANNELS = { grantRole: "agentcompany:grant-role", createChatSpace: "agentcompany:create-chat-space", sendMessage: "agentcompany:send-message", + editMessage: "agentcompany:edit-message", + deleteMessage: "agentcompany:delete-message", markChatSpaceRead: "agentcompany:mark-chat-space-read", saveProviderCredential: "agentcompany:save-provider-credential", revokeProviderCredential: "agentcompany:revoke-provider-credential", diff --git a/AgentCompany-New/tests/unit/chat-governance.test.ts b/AgentCompany-New/tests/unit/chat-governance.test.ts index d4ba9ecf..1edf58b7 100644 --- a/AgentCompany-New/tests/unit/chat-governance.test.ts +++ b/AgentCompany-New/tests/unit/chat-governance.test.ts @@ -78,6 +78,68 @@ describe("chat governance", () => { connection.close(); }); + test("audits chat message edits and deletes", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let employeeId = ""; + let agentId = ""; + let spaceId = ""; + let messageId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + agentId = organization.createAgent({ + orgId, + name: "Ada Lovelace", + role: "Reviewer", + department: "Engineering", + modelProviderId: null, + modelId: null, + budgetCents: 10000, + managerAgentId: null, + }); + spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" }); + messageId = chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "employee", + senderId: employeeId, + bodyMd: "Initial launch note @Ada-Lovelace", + attachmentFileIds: [], + }); + chat.editMessage({ + orgId, + messageId, + actorType: "employee", + actorId: employeeId, + bodyMd: `Updated launch note @${agentId}`, + }); + chat.deleteMessage({ orgId, messageId, actorType: "employee", actorId: employeeId }); + }); + + const state = snapshot.load(); + const message = state.chatMessages.find((item) => item.id === messageId); + expect(message).toMatchObject({ bodyMd: `Updated launch note @${agentId}` }); + expect(message?.editedAt).toBeTruthy(); + expect(message?.deletedAt).toBeTruthy(); + expect(state.chatMentions).toEqual([expect.objectContaining({ messageId, targetType: "agent", targetId: agentId })]); + expect(state.auditEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ entityType: "chat_message", entityId: messageId, action: "edited" }), + expect.objectContaining({ entityType: "chat_message", entityId: messageId, action: "deleted" }), + ])); + connection.close(); + }); + test("records board interventions and enforces thread control", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);