From 43e1626f77a858a4a19adda241d69e9c100f56b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 28 May 2026 19:44:35 -0400 Subject: [PATCH] Restrict board interventions to board-authority principals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any employee in the org could send a message as the board (freeze, takeover, force-approval), because requireSender only checked that the board sender was a valid employee. Enforce the approval_decider role (org_admin inherits it) via IdentityAccessService, which also records an access decision for denied attempts, and only render the thread board-control form for principals who hold that authority — others get a read-only view of past interventions. --- AgentCompany-New/src/main/services/chat-service.ts | 12 ++++++++++++ .../src/renderer/src/pages/ChatPage.tsx | 13 +++++++++++++ .../src/renderer/src/pages/chat/ChatThreadPanel.tsx | 8 ++++++++ 3 files changed, 33 insertions(+) diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index fd7eabe9..3b9c8c51 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -5,12 +5,14 @@ import { CHAT_THREAD_BOARD_STATE, FILE_SCAN_STATE, RISK_LEVEL, + ROLE_KEY, type FileScanState, } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { ApprovalService } from "./approval-service"; import { AuditService } from "./audit-service"; import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service"; +import { IdentityAccessService } from "./identity-access-service"; import { buildChatMessageStructure } from "./chat-message-structure"; import { ChatMessageMutationService } from "./chat-message-mutation-service"; import { recordChatMentions, resolveChatMentions, type ResolvedMention } from "./chat-mentions"; @@ -55,6 +57,7 @@ export class ChatService { private readonly boardInterventions: BoardInterventionService; private readonly handoffs: FileHandoffService; private readonly messageMutations: ChatMessageMutationService; + private readonly identity: IdentityAccessService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); @@ -62,6 +65,7 @@ export class ChatService { this.boardInterventions = new BoardInterventionService(db); this.handoffs = new FileHandoffService(db); this.messageMutations = new ChatMessageMutationService(db); + this.identity = new IdentityAccessService(db); } createSpace(input: CreateChatSpaceInput): string { @@ -400,6 +404,14 @@ export class ChatService { } if (!senderId) throw new Error("Board sender is required."); this.requireOrgEntity("employees", senderId, orgId, "Board sender"); + this.identity.assertEmployeeRole( + orgId, + senderId, + ROLE_KEY.APPROVAL_DECIDER, + "chat_board_intervention", + "organization", + orgId, + ); return { type: "employee", id: senderId }; } diff --git a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx index ecfed990..b5893e95 100644 --- a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx @@ -10,6 +10,7 @@ import { isRootMessage, replayEventsForThread, } from "./chat/chat-helpers"; +import { ROLE_KEY } from "../../../shared/domain"; import type { PageProps } from "./types"; import { activeOrgId, requireActiveEmployee } from "./types"; @@ -52,6 +53,17 @@ export function ChatPage({ snapshot, actions }: PageProps) { ); const channels = useMemo(() => spaces.filter((space) => space.spaceType !== "direct"), [spaces]); const directs = useMemo(() => spaces.filter((space) => space.spaceType === "direct"), [spaces]); + const canBoardIntervene = useMemo( + () => + snapshot.roleGrants.some( + (grant) => + grant.orgId === orgId && + grant.principalType === "employee" && + grant.principalId === employee.id && + (grant.roleKey === ROLE_KEY.APPROVAL_DECIDER || grant.roleKey === ROLE_KEY.ORG_ADMIN), + ), + [snapshot.roleGrants, orgId, employee.id], + ); const [selectedSpaceId, setSelectedSpaceId] = useState(null); const [openThreadId, setOpenThreadId] = useState(null); @@ -243,6 +255,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { files={files} auditEvents={threadAuditEvents} context={{ employees, agents }} + canIntervene={canBoardIntervene} onClose={() => setOpenThreadId(null)} onSubmit={sendInThread} onBoardSubmit={sendBoardIntervention} diff --git a/AgentCompany-New/src/renderer/src/pages/chat/ChatThreadPanel.tsx b/AgentCompany-New/src/renderer/src/pages/chat/ChatThreadPanel.tsx index c6e5409e..11b65777 100644 --- a/AgentCompany-New/src/renderer/src/pages/chat/ChatThreadPanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/chat/ChatThreadPanel.tsx @@ -25,6 +25,7 @@ export function ChatThreadPanel({ files, auditEvents, context, + canIntervene, onClose, onSubmit, onBoardSubmit, @@ -39,6 +40,7 @@ export function ChatThreadPanel({ files: FileObjectRecord[]; auditEvents: AuditEventRecord[]; context: MessageContext; + canIntervene: boolean; onClose: () => void; onSubmit: (event: FormEvent) => void; onBoardSubmit: (event: FormEvent) => void; @@ -67,6 +69,7 @@ export function ChatThreadPanel({ thread={thread} interventions={boardInterventions} context={context} + canIntervene={canIntervene} onSubmit={onBoardSubmit} /> {rootMessage && ( @@ -132,20 +135,24 @@ function BoardControl({ thread, interventions, context, + canIntervene, onSubmit, }: { thread: ChatThreadRecord; interventions: BoardInterventionRecord[]; context: MessageContext; + canIntervene: boolean; onSubmit: (event: FormEvent) => void; }) { const [action, setAction] = useState("decision"); + if (!canIntervene && interventions.length === 0) return null; return (
Board
{boardStateLabel(thread.boardState)}
+ {canIntervene && (