From 754f9ba44b2ebdde97564c80ee5a2ee9418d64c6 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, 8 Oct 2026 19:10:40 -0400 Subject: [PATCH] Q118: read_messages reads restricted channels and direct threads only for their members Co-Authored-By: Claude Opus 5.5 --- src/main/services/agent-read-messages.ts | 11 ++++++++++- tests/unit/agent-direct-messages.test.ts | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/services/agent-read-messages.ts b/src/main/services/agent-read-messages.ts index c55aea79..fb233b59 100644 --- a/src/main/services/agent-read-messages.ts +++ b/src/main/services/agent-read-messages.ts @@ -15,6 +15,13 @@ interface MessageFilter { limit: number; } +/** Workspace-wide channels are open to every agent; restricted channels and direct conversations only to their members. */ +function mayRead(deps: AgentToolDeps, ctx: AgentToolContext, spaceId: string): boolean { + const space = deps.db.prepare("select visibility, space_type from chat_spaces where id = ?").get(spaceId) as { visibility: string; space_type: string }; + if (space.visibility === "workspace" && space.space_type !== "direct") return true; + return Boolean(deps.db.prepare("select 1 from chat_memberships where space_id = ? and principal_type = 'agent' and principal_id = ?").get(spaceId, ctx.agent.id)); +} + export function readMessagesTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { const channel = optionalString(args.channel); const withTeammate = optionalString(args.with); @@ -47,11 +54,13 @@ export function readMessagesTool(deps: AgentToolDeps, ctx: AgentToolContext, arg "select id from chat_spaces where org_id = ? and lower(name) = lower(?) and space_type <> 'direct' limit 1", ).get(ctx.orgId, channel) as { id: string } | undefined; if (!space) throw new Error(`No channel named "${channel}".`); + if (!mayRead(deps, ctx, space.id)) throw new Error(`You are not a member of #${channel}, so you cannot read it.`); return messagePage(deps, ctx.orgId, { column: "space_id", id: space.id }, filter, `#${channel}`); } if (threadId) { - const thread = deps.db.prepare("select id from chat_threads where id = ? and org_id = ?").get(threadId, ctx.orgId); + const thread = deps.db.prepare("select space_id from chat_threads where id = ? and org_id = ?").get(threadId, ctx.orgId) as { space_id: string } | undefined; if (!thread) throw new Error(`No thread with thread_id "${threadId}".`); + if (!mayRead(deps, ctx, thread.space_id)) throw new Error(`You are not a member of the conversation thread ${threadId} belongs to, so you cannot read it.`); return messagePage(deps, ctx.orgId, { column: "thread_id", id: threadId }, filter, `thread ${threadId}`); } return unansweredMentions(deps, ctx, filter.limit); diff --git a/tests/unit/agent-direct-messages.test.ts b/tests/unit/agent-direct-messages.test.ts index 42aff882..1a458ef8 100644 --- a/tests/unit/agent-direct-messages.test.ts +++ b/tests/unit/agent-direct-messages.test.ts @@ -155,4 +155,23 @@ describe("agent direct messages", () => { expect(readBack).toContain("priorities for today"); expect(readBack).toMatch(/\] CEO \(agent, [^)]+\) \(thread=/); }); + + test("read_messages reads only where the agent may: restricted channels and others' direct messages need membership", () => { + const h = harness({ enableProvider: false }); + const deps = { db: h.connection.db, organization: h.organization, chat: h.chat }; + const dana = { ...h.ctx, agent: { id: h.danaId, modelProviderId: null, modelId: null } }; + h.connection.transaction(() => h.chat.createSpace({ orgId: h.orgId, spaceType: "project", name: "board-room", topic: "t", visibility: "restricted", memberAgentIds: [h.ceoId] })); + dispatchAgentTool(deps, h.ctx, call("send_message", { channel: "board-room", body: "Acquisition terms are 4x revenue." })); + expect(dispatchAgentTool(deps, h.ctx, call("read_messages", { channel: "board-room" }))).toContain("Acquisition terms"); + expect(dispatchAgentTool(deps, dana, call("read_messages", { channel: "board-room" }))).toBe("ERROR: You are not a member of #board-room, so you cannot read it."); + + const eveId = h.connection.transaction(() => h.organization.createAgent({ + orgId: h.orgId, name: "Eve", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: h.ceoId, + })); + const sent = dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "Your raise is approved." })); + const threadId = /thread_id="([^"]+)"/.exec(sent)![1]; + expect(dispatchAgentTool(deps, dana, call("read_messages", { thread_id: threadId }))).toContain("Your raise is approved."); + const eve = { ...h.ctx, agent: { id: eveId, modelProviderId: null, modelId: null } }; + expect(dispatchAgentTool(deps, eve, call("read_messages", { thread_id: threadId }))).toBe(`ERROR: You are not a member of the conversation thread ${threadId} belongs to, so you cannot read it.`); + }); });