From 91fa6b5f1e531ab5a0788817232c857d59e1881e 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:52:37 -0400 Subject: [PATCH] Q123: files shared in a private conversation stay with its members Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/platform.ts | 2 +- src/main/services/agent-attachment-tools.ts | 7 ++- src/main/services/agent-task-handoff.ts | 10 +++- src/main/services/agent-tools.ts | 2 +- src/main/services/file-service.ts | 15 +++++- src/main/services/files/file-object-store.ts | 29 ++++++++++- src/renderer/src/pages/ChatPage.tsx | 2 +- src/shared/commands/files.ts | 2 + tests/unit/agent-attachment-tools.test.ts | 5 ++ tests/unit/domain-services.test.ts | 4 +- tests/unit/private-attachments.test.ts | 53 ++++++++++++++++++++ 11 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 tests/unit/private-attachments.test.ts diff --git a/src/main/app/ipc-contracts/platform.ts b/src/main/app/ipc-contracts/platform.ts index 8278412a..a05612b6 100644 --- a/src/main/app/ipc-contracts/platform.ts +++ b/src/main/app/ipc-contracts/platform.ts @@ -49,7 +49,7 @@ export const platformContracts = { setScheduledPostState: z.strictObject({ orgId: id, scheduledPostId: id, state: z.enum(["draft", "scheduled", "published", "cancelled"]), employeeId: id, }) satisfies z.ZodType, - importFile: z.strictObject({ orgId: id }) satisfies z.ZodType, + importFile: z.strictObject({ orgId: id, spaceId: id.optional() }) satisfies z.ZodType, openFile: z.strictObject({ orgId: id, fileId: id }) satisfies z.ZodType, setFilePinned: z.strictObject({ orgId: id, fileId: id, pinned: z.boolean() }) satisfies z.ZodType, createMigrationReport: z.strictObject({ orgId: id, sourceDatabasePath: z.string() }) satisfies z.ZodType, diff --git a/src/main/services/agent-attachment-tools.ts b/src/main/services/agent-attachment-tools.ts index f15a5731..dc535849 100644 --- a/src/main/services/agent-attachment-tools.ts +++ b/src/main/services/agent-attachment-tools.ts @@ -1,4 +1,4 @@ -import { readVerifiedFile } from "./files/file-object-store"; +import { agentCanReadFile, readVerifiedFile } from "./files/file-object-store"; import type { DatabaseSync } from "node:sqlite"; import { FILE_SCAN_STATE } from "../../shared/domain"; import { requireString } from "./agent-tool-args"; @@ -30,11 +30,16 @@ export function attachmentNote(db: DatabaseSync, messageId: string): string { return ` [attachments: ${files.map((file) => `${file.name} (file_id=${file.id})`).join(", ")}]`; } +export function noAccess(name: string): string { + return `You have no access to ${name}; it was shared in a conversation you are not part of.`; +} + export function readAttachmentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { const fileId = requireString(args.file_id, "file_id"); const file = deps.db.prepare("select name, storage_path, sha256, scan_state from file_objects where id = ? and org_id = ?") .get(fileId, ctx.orgId) as { name: string; storage_path: string; sha256: string; scan_state: string } | undefined; if (!file) throw new Error(`No file with file_id "${fileId}".`); + if (!agentCanReadFile(deps.db, ctx.orgId, fileId, ctx.agent.id)) throw new Error(noAccess(file.name)); if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error(`${file.name} was blocked by the file scan and cannot be read.`); const bytes = readVerifiedFile({ name: file.name, storagePath: file.storage_path, sha256: file.sha256 }); const content = utf8Text(bytes); diff --git a/src/main/services/agent-task-handoff.ts b/src/main/services/agent-task-handoff.ts index 29565d61..854501d9 100644 --- a/src/main/services/agent-task-handoff.ts +++ b/src/main/services/agent-task-handoff.ts @@ -1,4 +1,5 @@ -import { readVerifiedFile } from "./files/file-object-store"; +import { agentCanReadFile, readVerifiedFile } from "./files/file-object-store"; +import { noAccess } from "./agent-attachment-tools"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import type { DatabaseSync } from "node:sqlite"; @@ -24,6 +25,8 @@ export const HANDOFF_TOOL_PROPERTIES = { export function buildTaskHandoff( db: DatabaseSync, orgId: string, + agentId: string, + assignee: { id: string; name: string } | null, source: { taskId: string | null; threadId: string | null }, args: JsonRecord, ): TaskHandoff { @@ -31,6 +34,11 @@ export function buildTaskHandoff( const file = db.prepare("select id, name, storage_path, sha256 from file_objects where id = ? and org_id = ?") .get(fileId, orgId) as { id: string; name: string; storage_path: string; sha256: string } | undefined; if (!file) throw new Error(`No file with file_id "${fileId}" exists in this company.`); + // A task's assignee gets its inputs, so only files the delegating agent may read can be handed on. + if (!agentCanReadFile(db, orgId, file.id, agentId)) throw new Error(noAccess(file.name)); + if (assignee && !agentCanReadFile(db, orgId, file.id, assignee.id)) { + throw new Error(`${assignee.name} has no access to ${file.name}; it was shared in a conversation they are not part of. Share it with them or assign someone who has it.`); + } // The handoff pins the recorded content; bytes that no longer match it are refused, not pinned as if they were. readVerifiedFile({ name: file.name, storagePath: file.storage_path, sha256: file.sha256 }); return { fileId: file.id, name: file.name, sha256: file.sha256 }; diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 0bb124eb..02638b50 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -347,7 +347,7 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord const description = requireString(args.description, "description"); const assigneeName = optionalString(args.assignee_agent_name); const assignee = assigneeName ? requireRunnableAgentByName(deps.db, ctx.orgId, assigneeName) : null; - const handoff = buildTaskHandoff(deps.db, ctx.orgId, { taskId: ctx.taskId, threadId: ctx.sourceThreadId }, args); + const handoff = buildTaskHandoff(deps.db, ctx.orgId, ctx.agent.id, assignee, { taskId: ctx.taskId, threadId: ctx.sourceThreadId }, args); const parentTaskId = delegationParent(deps.db, ctx); const writingMode = optionalEnum(args.writing_mode, "writing_mode", Object.values(WRITING_MODE), WRITING_MODE.CONVERSATIONAL) as WritingMode; const create = () => { diff --git a/src/main/services/file-service.ts b/src/main/services/file-service.ts index 35a07263..513e63b0 100644 --- a/src/main/services/file-service.ts +++ b/src/main/services/file-service.ts @@ -26,10 +26,12 @@ export class FileService { securityScopedBookmarks: true, }); if (result.canceled || result.filePaths.length === 0) return null; - return this.importPath(input.orgId, result.filePaths[0]); + return this.importPath(input.orgId, result.filePaths[0], input.spaceId ?? null); } - importPath(orgId: string, sourcePath: string): string { + /** A file imported into a direct conversation or restricted channel is readable by that space's members only. */ + importPath(orgId: string, sourcePath: string, spaceId: string | null): string { + const access = spaceId ? spaceAccess(this.db, orgId, spaceId) : "workspace"; const id = createId("fil"); const now = new Date().toISOString(); const stats = statSync(sourcePath); @@ -53,6 +55,7 @@ export class FileService { sha256: hash, storagePath: target, sourcePath: target, + access, ownerType: "system", ownerId: null, scan, @@ -104,3 +107,11 @@ export class FileService { } } + +function spaceAccess(db: DatabaseSync, orgId: string, spaceId: string): "workspace" | Array<{ type: "employee" | "agent"; id: string }> { + const space = db.prepare("select visibility, space_type from chat_spaces where id = ? and org_id = ?").get(spaceId, orgId) as { visibility: string; space_type: string } | undefined; + if (!space) throw new Error("The conversation to import into was not found."); + if (space.visibility === "workspace" && space.space_type !== "direct") return "workspace"; + return db.prepare("select principal_type as type, principal_id as id from chat_memberships where space_id = ? and principal_type in ('employee', 'agent')") + .all(spaceId) as Array<{ type: "employee" | "agent"; id: string }>; +} diff --git a/src/main/services/files/file-object-store.ts b/src/main/services/files/file-object-store.ts index 717c1984..db64bab9 100644 --- a/src/main/services/files/file-object-store.ts +++ b/src/main/services/files/file-object-store.ts @@ -77,6 +77,8 @@ export interface FileObjectInput { storagePath: string; /** The path the file came from; one id per content of it, the newest current and older ones superseded. */ sourcePath: string; + /** Who may read it once it exists: the whole workspace, or only these principals (a private conversation's members). */ + access: "workspace" | Array<{ type: "employee" | "agent"; id: string }>; ownerType: string; ownerId: string | null; scan: FileScanResult; @@ -145,7 +147,8 @@ function writeFileObject(db: DatabaseSync, input: FileObjectInput): string { input.now, input.now, ); - grantWorkspaceAccess(db, input.orgId, id, input.now); + if (input.access === "workspace") grantWorkspaceAccess(db, input.orgId, id, input.now); + else for (const principal of input.access) grantPrincipalAccess(db, input.orgId, id, principal, input.now); return id; } @@ -177,6 +180,7 @@ export function recordWorkspaceFile(db: DatabaseSync, input: { sha256, storagePath: copy, sourcePath: absolute, + access: "workspace", ownerType: input.ownerType, ownerId: input.ownerId, scan, @@ -227,6 +231,29 @@ function isInside(root: string, path: string): boolean { return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); } +/** Whether an agent may read a file: it owns it, or holds a grant of its own or the workspace's. */ +export function agentCanReadFile(db: DatabaseSync, orgId: string, fileId: string, agentId: string): boolean { + return Boolean(db.prepare(` + select 1 from file_objects f where f.id = ? and f.org_id = ? and ( + (f.owner_type = 'agent' and f.owner_id = ?) + or exists ( + select 1 from file_access_grants g where g.file_id = f.id and g.access_level in ('read', 'manage') + and ((g.granted_to_type = 'agent' and g.granted_to_id = ?) or (g.granted_to_type = 'org_group' and g.granted_to_id = ?)) + ) + ) + `).get(fileId, orgId, agentId, agentId, `${orgId}:workspace`)); +} + +function grantPrincipalAccess(db: DatabaseSync, orgId: string, fileId: string, principal: { type: string; id: string }, now: string): void { + db.prepare(` + insert into file_access_grants( + id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id, + source_message_id, source_thread_id, access_level, created_at + ) + values (?, ?, ?, ?, ?, 'system', null, null, null, 'read', ?) + `).run(createId("fgr"), orgId, fileId, principal.type, principal.id, now); +} + function grantWorkspaceAccess(db: DatabaseSync, orgId: string, fileId: string, now: string): void { db.prepare(` insert into file_access_grants( diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 69c706f5..897cc7b9 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -175,7 +175,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { }; const importAttachment = (onSelect: (fileId: string) => void) => { - void window.agentCompany.importFile({ orgId }).then((result) => { + void window.agentCompany.importFile({ orgId, ...(activeSpaceId ? { spaceId: activeSpaceId } : {}) }).then((result) => { if (!result.ok) throw new Error(result.error.message); if (result.data) onSelect(result.data); }); diff --git a/src/shared/commands/files.ts b/src/shared/commands/files.ts index 7142c24d..4ab39308 100644 --- a/src/shared/commands/files.ts +++ b/src/shared/commands/files.ts @@ -1,5 +1,7 @@ export interface ImportFileInput { orgId: string; + /** The conversation the file is attached in; a direct or restricted one keeps the file to its members. */ + spaceId?: string; } export interface OpenFileInput { diff --git a/tests/unit/agent-attachment-tools.test.ts b/tests/unit/agent-attachment-tools.test.ts index 59fe0442..a394fcb0 100644 --- a/tests/unit/agent-attachment-tools.test.ts +++ b/tests/unit/agent-attachment-tools.test.ts @@ -25,6 +25,11 @@ function harness() { insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at, scan_state) values (?, ?, ?, 'md', 1, ?, ?, 'employee', null, ?, ?) `).run(fileId, owner, name, createHash("sha256").update(content).digest("hex"), path, new Date().toISOString(), scanState); + // As every recorded file is, it is readable across its company's workspace. + connection.db.prepare(` + insert into file_access_grants(id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, access_level, created_at) + values (?, ?, ?, 'org_group', ?, 'system', 'manage', ?) + `).run(`fgr_${randomUUID()}`, owner, fileId, `${owner}:workspace`, new Date().toISOString()); return fileId; }; const ctx: AgentToolContext = { diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 75295f31..aed2a4ed 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -483,8 +483,8 @@ describe("domain services", () => { goal: "Ship a verified control plane.", }); }); - const cleanFileId = fileService.importPath(orgId, cleanPath); - const blockedFileId = fileService.importPath(orgId, secretPath); + const cleanFileId = fileService.importPath(orgId, cleanPath, null); + const blockedFileId = fileService.importPath(orgId, secretPath, null); const state = snapshot.load(); const cleanFile = state.files.find((file) => file.id === cleanFileId); diff --git a/tests/unit/private-attachments.test.ts b/tests/unit/private-attachments.test.ts new file mode 100644 index 00000000..eb394bda --- /dev/null +++ b/tests/unit/private-attachments.test.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("electron", () => ({ dialog: {}, shell: {} })); + +const { DatabaseConnection } = await import("../../src/main/database/connection"); +const { OrganizationService } = await import("../../src/main/services/organization-service"); +const { ChatService } = await import("../../src/main/services/chat-service"); +const { FileService } = await import("../../src/main/services/file-service"); +const { dispatchAgentTool } = await import("../../src/main/services/agent-tools"); + +describe("files shared in a private conversation", () => { + test("a file imported into a direct conversation is readable by its members only", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-private-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + let orgId = ""; + let danaId = ""; + let eveId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + connection.db.prepare("update providers set enabled = 1 where id = ?").run(providerId); + danaId = organization.createAgent({ orgId, name: "Dana", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null }); + eveId = organization.createAgent({ orgId, name: "Eve", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null }); + }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + const dm = connection.transaction(() => chat.ensureDirectSpace(orgId, { type: "employee", id: employeeId }, { type: "agent", id: danaId })); + const source = join(tmpdir(), `ac-private-src-${randomUUID()}`); + mkdirSync(source, { recursive: true }); + writeFileSync(join(source, "salary.md"), "Dana's raise: 10%"); + const artifacts = join(tmpdir(), `ac-private-art-${randomUUID()}`); + const fileId = new FileService(connection.db, artifacts).importPath(orgId, join(source, "salary.md"), dm); + + const deps = { db: connection.db, organization, chat }; + const as = (agentId: string) => ({ orgId, agent: { id: agentId, modelProviderId: null, modelId: null }, taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null }); + expect(dispatchAgentTool(deps, as(danaId), { id: "c1", name: "read_attachment", arguments: { file_id: fileId } })).toContain("Dana's raise"); + expect(dispatchAgentTool(deps, as(eveId), { id: "c2", name: "read_attachment", arguments: { file_id: fileId } })) + .toBe("ERROR: You have no access to salary.md; it was shared in a conversation you are not part of."); + expect(dispatchAgentTool(deps, as(eveId), { id: "c3", name: "create_task", arguments: { title: "Leak", description: "d", input_file_ids: [fileId] } })) + .toBe("ERROR: You have no access to salary.md; it was shared in a conversation you are not part of."); + // A member cannot hand the file on to someone outside the conversation either. + expect(dispatchAgentTool(deps, as(danaId), { id: "c4", name: "create_task", arguments: { title: "Pass on", description: "d", assignee_agent_name: "Eve", input_file_ids: [fileId] } })) + .toBe("ERROR: Eve has no access to salary.md; it was shared in a conversation they are not part of. Share it with them or assign someone who has it."); + expect(connection.db.prepare("select count(*) as n from tasks where title in ('Leak', 'Pass on')").get()).toEqual({ n: 0 }); + expect(connection.db.prepare("select granted_to_type as type from file_access_grants where file_id = ? order by granted_to_type").all(fileId)) + .toEqual([{ type: "agent" }, { type: "employee" }]); + connection.close(); + }); +});