Q123: files shared in a private conversation stay with its members
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
69b83d6b77
commit
91fa6b5f1e
11 files changed
+121
-10
No files matched your search
@@ -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<C.SetScheduledPostStateInput>,
|
||||
importFile: z.strictObject({ orgId: id }) satisfies z.ZodType<C.ImportFileInput>,
|
||||
importFile: z.strictObject({ orgId: id, spaceId: id.optional() }) satisfies z.ZodType<C.ImportFileInput>,
|
||||
openFile: z.strictObject({ orgId: id, fileId: id }) satisfies z.ZodType<C.OpenFileInput>,
|
||||
setFilePinned: z.strictObject({ orgId: id, fileId: id, pinned: z.boolean() }) satisfies z.ZodType<C.SetFilePinnedInput>,
|
||||
createMigrationReport: z.strictObject({ orgId: id, sourceDatabasePath: z.string() }) satisfies z.ZodType<C.CreateMigrationReportInput>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user