diff --git a/AgentCompany-New/src/main/database/migrations/010-file-handoff-security.ts b/AgentCompany-New/src/main/database/migrations/010-file-handoff-security.ts new file mode 100644 index 00000000..4f30e961 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/010-file-handoff-security.ts @@ -0,0 +1,52 @@ +export const fileHandoffSecuritySchema = ` + alter table file_objects add column scan_state text not null default 'approved'; + alter table file_objects add column scan_labels_json text not null default '[]'; + alter table file_objects add column scanned_at text; + + create table if not exists file_access_grants ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + file_id text not null references file_objects(id) on delete cascade, + granted_to_type text not null, + granted_to_id text not null, + granted_by_type text not null, + granted_by_id text, + source_message_id text references chat_messages(id) on delete set null, + source_thread_id text references chat_threads(id) on delete set null, + access_level text not null, + created_at text not null, + unique(file_id, granted_to_type, granted_to_id, source_message_id, access_level) + ); + + 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 + ) + select + 'fgr_' || lower(hex(randomblob(16))), + org_id, + id, + 'org_group', + org_id || ':workspace', + 'system', + null, + null, + null, + 'manage', + coalesce(created_at, datetime('now')) + from file_objects + where owner_type = 'system' + and not exists ( + select 1 from file_access_grants + where file_access_grants.file_id = file_objects.id + and file_access_grants.granted_to_type = 'org_group' + and file_access_grants.granted_to_id = file_objects.org_id || ':workspace' + ); + + create index if not exists idx_file_grants_org_file on file_access_grants(org_id, file_id); + create index if not exists idx_file_grants_principal on file_access_grants(org_id, granted_to_type, granted_to_id); + create index if not exists idx_files_org_scan on file_objects(org_id, scan_state); + + insert into schema_migrations(version, name, applied_at) + values (10, 'file_handoff_security', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index d2f4241d..307f777b 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -8,6 +8,7 @@ import { appStateSchema } from "./migrations/006-app-state"; import { skillHardeningSchema } from "./migrations/007-skill-hardening"; import { providerHealthSchema } from "./migrations/008-provider-health"; import { socialGovernanceSchema } from "./migrations/009-social-governance"; +import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security"; interface Migration { version: number; @@ -25,6 +26,7 @@ const MIGRATIONS: Migration[] = [ { version: 7, name: "skill_hardening", sql: skillHardeningSchema }, { version: 8, name: "provider_health", sql: providerHealthSchema }, { version: 9, name: "social_governance", sql: socialGovernanceSchema }, + { version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 8a89078e..032065aa 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands"; -import { RISK_LEVEL } from "../../shared/domain"; +import { FILE_SCAN_STATE, RISK_LEVEL } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { ApprovalService } from "./approval-service"; import { AuditService } from "./audit-service"; @@ -14,6 +14,14 @@ interface AttachmentCandidate { checksum: string; } +interface FileAttachmentRow { + id: string; + sha256: string; + scan_state: string; + owner_type: string; + owner_id: string | null; +} + interface SpaceRow { id: string; org_id: string; @@ -93,7 +101,7 @@ export class ChatService { const slash = parseSlashInvocation(body); if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`); if (slash) validateSlashInvocation(slash); - const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? []); + const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? [], sender); const mentions = this.resolveMentions(input.orgId, input.spaceId, body); const id = createId("msg"); @@ -127,6 +135,7 @@ export class ChatService { const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, slash, id, threadId) : null; if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now); this.recordAttachments(id, attachments); + this.recordAttachmentGrants(input, id, threadId, attachments, mentions, now); if (input.senderId) { const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active"; this.db.prepare(` @@ -244,6 +253,53 @@ export class ChatService { } } + private recordAttachmentGrants( + input: SendMessageInput, + messageId: string, + threadId: string, + attachments: AttachmentCandidate[], + mentions: ResolvedMention[], + now: string, + ): void { + if (attachments.length === 0) return; + const recipients = this.attachmentGrantRecipients(input.orgId, input.spaceId, mentions); + const statement = this.db.prepare(` + insert or ignore 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const attachment of attachments) { + for (const recipient of recipients) { + const result = statement.run( + createId("fgr"), + input.orgId, + attachment.fileId, + recipient.type, + recipient.id, + input.senderType, + input.senderId, + messageId, + threadId, + "read", + now, + ); + if (result.changes > 0) { + this.audit.record({ + orgId: input.orgId, + entityType: "file_object", + entityId: attachment.fileId, + actorType: input.senderType, + actorId: input.senderId, + action: "access_granted", + payload: { messageId, threadId, grantedToType: recipient.type, grantedToId: recipient.id }, + }); + } + } + } + } + private requireSpace(orgId: string, spaceId: string): SpaceRow { const space = this.db.prepare("select id, org_id, visibility, space_type from chat_spaces where id = ? and org_id = ?") .get(spaceId, orgId) as SpaceRow | undefined; @@ -274,15 +330,68 @@ export class ChatService { if (!thread) throw new Error("Chat thread was not found in this space."); } - private requireAttachments(orgId: string, fileIds: string[]): AttachmentCandidate[] { + private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): AttachmentCandidate[] { + if (new Set(fileIds).size !== fileIds.length) throw new Error("Attachment file ids must be unique."); return fileIds.map((fileId) => { - const file = this.db.prepare("select sha256 from file_objects where id = ? and org_id = ?") - .get(fileId, orgId) as { sha256: string } | undefined; + const file = this.db.prepare(` + select id, sha256, scan_state, owner_type, owner_id + from file_objects + where id = ? and org_id = ? + `).get(fileId, orgId) as FileAttachmentRow | undefined; if (!file) throw new Error("Attachment file was not found."); + if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error("Attachment file is not approved for handoff."); + if (!this.senderCanReadFile(orgId, file, sender)) throw new Error("Attachment file access is required."); return { fileId, checksum: file.sha256 }; }); } + private senderCanReadFile(orgId: string, file: FileAttachmentRow, sender: PrincipalRef | null): boolean { + if (!sender) return true; + if (file.owner_type === sender.type && file.owner_id === sender.id) return true; + const workspaceGroupId = `${orgId}:workspace`; + const grant = this.db.prepare(` + select id from file_access_grants + where org_id = ? + and file_id = ? + and access_level in ('read', 'manage') + and ( + (granted_to_type = ? and granted_to_id = ?) + or (granted_to_type = 'org_group' and granted_to_id = ?) + ) + limit 1 + `).get(orgId, file.id, sender.type, sender.id, workspaceGroupId) as { id: string } | undefined; + return Boolean(grant); + } + + private attachmentGrantRecipients(orgId: string, spaceId: string, mentions: ResolvedMention[]): MentionTarget[] { + const source = mentions.length > 0 ? mentions.map((mention) => mention.target) : this.spaceRecipients(spaceId); + const seen = new Set(); + const recipients: MentionTarget[] = []; + for (const target of source) { + const key = `${target.type}:${target.id}`; + if (seen.has(key)) continue; + seen.add(key); + recipients.push(target); + } + if (recipients.length === 0) throw new Error("Attachment grant recipient is required."); + return recipients; + } + + private spaceRecipients(spaceId: string): MentionTarget[] { + const rows = this.db.prepare(` + select principal_type, principal_id + from chat_memberships + where space_id = ? + order by principal_type asc, principal_id asc + `).all(spaceId) as Array<{ principal_type: string; principal_id: string }>; + return rows.map((row) => { + if (row.principal_type === "employee" || row.principal_type === "agent") { + return { type: row.principal_type, id: row.principal_id }; + } + throw new Error("Chat membership principal type is unsupported."); + }); + } + 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; diff --git a/AgentCompany-New/src/main/services/file-service.ts b/AgentCompany-New/src/main/services/file-service.ts index 19b8f7c9..b8c6edc7 100644 --- a/AgentCompany-New/src/main/services/file-service.ts +++ b/AgentCompany-New/src/main/services/file-service.ts @@ -4,9 +4,16 @@ import { basename, extname, join } from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { dialog } from "electron"; import type { ImportFileInput } from "../../shared/commands"; +import { FILE_SCAN_STATE } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; +interface FileScanResult { + state: typeof FILE_SCAN_STATE.APPROVED; + labels: string[]; + scannedAt: string; +} + function fileKind(path: string): string { const ext = extname(path).toLowerCase().replace(".", ""); if (!ext) return "file"; @@ -43,41 +50,54 @@ export class FileService { const now = new Date().toISOString(); const stats = statSync(sourcePath); if (!stats.isFile()) throw new Error("Selected path is not a file."); - const hash = createHash("sha256").update(readFileSync(sourcePath)).digest("hex"); + const bytes = readFileSync(sourcePath); + const hash = createHash("sha256").update(bytes).digest("hex"); + const scan = scanFile(sourcePath, bytes); const orgDir = join(this.artifactRoot, orgId); mkdirSync(orgDir, { recursive: true }); const target = join(orgDir, `${id}-${basename(sourcePath)}`); copyFileSync(sourcePath, target); - this.db.prepare(` - insert into file_objects( - id, org_id, name, kind, size_bytes, sha256, storage_path, - owner_type, owner_id, space_id, pinned, created_at, updated_at - ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - id, - orgId, - basename(sourcePath), - fileKind(sourcePath), - stats.size, - hash, - target, - "system", - null, - null, - 0, - now, - now, - ); - this.audit.record({ - orgId, - entityType: "file_object", - entityId: id, - actorType: "system", - actorId: null, - action: "imported", - payload: { sha256: hash, sizeBytes: stats.size }, - }); + this.db.exec("begin immediate"); + try { + this.db.prepare(` + insert into file_objects( + id, org_id, name, kind, size_bytes, sha256, storage_path, + owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + orgId, + basename(sourcePath), + fileKind(sourcePath), + stats.size, + hash, + target, + "system", + null, + null, + 0, + scan.state, + JSON.stringify(scan.labels), + scan.scannedAt, + now, + now, + ); + this.grantWorkspaceAccess(orgId, id, now); + this.audit.record({ + orgId, + entityType: "file_object", + entityId: id, + actorType: "system", + actorId: null, + action: "imported", + payload: { sha256: hash, sizeBytes: stats.size, scanState: scan.state }, + }); + this.db.exec("commit"); + } catch (error) { + this.db.exec("rollback"); + throw error; + } return id; } @@ -96,4 +116,45 @@ export class FileService { payload: { fileId }, }); } + + private grantWorkspaceAccess(orgId: string, fileId: string, now: string): void { + this.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + createId("fgr"), + orgId, + fileId, + "org_group", + `${orgId}:workspace`, + "system", + null, + null, + null, + "manage", + now, + ); + } +} + +function scanFile(sourcePath: string, bytes: Buffer): FileScanResult { + const sample = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("latin1"); + if (sample.includes("EICAR-STANDARD-ANTIVIRUS-TEST-FILE")) { + throw new Error("File scan blocked a known malware signature."); + } + return { + state: FILE_SCAN_STATE.APPROVED, + labels: scanLabels(sourcePath, bytes.length), + scannedAt: new Date().toISOString(), + }; +} + +function scanLabels(sourcePath: string, sizeBytes: number): string[] { + const labels = new Set([`kind:${fileKind(sourcePath)}`]); + if (sizeBytes === 0) labels.add("empty"); + if (sizeBytes > 100 * 1024 * 1024) labels.add("large"); + return [...labels].sort(); } diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index ccd61237..df16cf91 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -37,6 +37,7 @@ const LEGACY_TABLES = [ "skill_runtime_snapshots", "skill_catalog_tags", "file_objects", + "file_access_grants", "social_accounts", "social_account_grants", "browser_actions", @@ -136,6 +137,10 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "skill_catalog_tags", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" }, { table: "file_objects", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "file_objects", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" }, + { table: "file_access_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "file_access_grants", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" }, + { table: "file_access_grants", column: "source_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_source_message_id" }, + { table: "file_access_grants", column: "source_thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_source_thread_id" }, { table: "social_accounts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "social_accounts", column: "authorized_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_authorized_employee_id" }, { table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 328f8ad8..a4d5a4e7 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -68,6 +68,7 @@ export class SnapshotService { chatPresence: list(this.db, "chat_presence", "last_seen_at desc"), chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"), files, + fileAccessGrants: list(this.db, "file_access_grants", "created_at desc"), scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"), providers, providerCatalog: PROVIDER_CATALOG, diff --git a/AgentCompany-New/src/renderer/src/pages/FilesPage.tsx b/AgentCompany-New/src/renderer/src/pages/FilesPage.tsx index 9d08efc8..d6d7123a 100644 --- a/AgentCompany-New/src/renderer/src/pages/FilesPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/FilesPage.tsx @@ -3,6 +3,7 @@ import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui" import type { AgentRecord, EmployeeRecord, + FileAccessGrantRecord, FileObjectRecord, } from "../../../shared/domain"; import type { PageProps } from "./types"; @@ -30,6 +31,10 @@ export function FilesPage({ snapshot, actions }: PageProps) { const employees = snapshot.employees.filter((employee) => employee.orgId === orgId); const agents = snapshot.agents.filter((agent) => agent.orgId === orgId); const files = useMemo(() => snapshot.files.filter((file) => file.orgId === orgId), [snapshot.files, orgId]); + const fileAccessGrants = useMemo( + () => snapshot.fileAccessGrants.filter((grant) => grant.orgId === orgId), + [snapshot.fileAccessGrants, orgId], + ); const [section, setSection] = useState
({ kind: "all" }); const [view, setView] = useState("list"); @@ -83,6 +88,7 @@ export function FilesPage({ snapshot, actions }: PageProps) { key={file.id} file={file} ownerName={resolveOwnerName(file, employees, agents)} + grantCount={fileGrantCount(file.id, fileAccessGrants)} onUnpin={() => void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: false }))} /> ))} @@ -98,6 +104,7 @@ export function FilesPage({ snapshot, actions }: PageProps) { {view === "list" ? ( void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: !file.pinned }))} @@ -105,6 +112,7 @@ export function FilesPage({ snapshot, actions }: PageProps) { ) : ( void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: !file.pinned }))} @@ -213,11 +221,13 @@ function TreeItem({ function FilesTable({ files, + grants, employees, agents, onTogglePin, }: { files: FileObjectRecord[]; + grants: FileAccessGrantRecord[]; employees: EmployeeRecord[]; agents: AgentRecord[]; onTogglePin: (file: FileObjectRecord) => void; @@ -226,15 +236,16 @@ function FilesTable({
-
Name
Size
Owner
Modified
+
Name
Size
Owner
Access
Modified
{files.map((file, index) => ( onTogglePin(file)} /> @@ -245,11 +256,13 @@ function FilesTable({ function FilesGrid({ files, + grants, employees, agents, onTogglePin, }: { files: FileObjectRecord[]; + grants: FileAccessGrantRecord[]; employees: EmployeeRecord[]; agents: AgentRecord[]; onTogglePin: (file: FileObjectRecord) => void; @@ -261,6 +274,7 @@ function FilesGrid({ key={file.id} file={file} ownerName={resolveOwnerName(file, employees, agents)} + grantCount={fileGrantCount(file.id, grants)} onUnpin={() => onTogglePin(file)} /> ))} @@ -271,11 +285,13 @@ function FilesGrid({ function FileRow({ file, owner, + grantCount, last, onTogglePin, }: { file: FileObjectRecord; owner: string; + grantCount: number; last: boolean; onTogglePin: () => void; }) { @@ -283,7 +299,7 @@ function FileRow({
@@ -297,6 +313,10 @@ function FileRow({ {owner}
+
+ {file.scanState === "approved" ? "Clean" : "Blocked"} + {grantCount} +
{formatRelative(file.updatedAt ?? file.createdAt)}
@@ -308,10 +328,12 @@ function FileRow({ function PinnedCard({ file, ownerName, + grantCount, onUnpin, }: { file: FileObjectRecord; ownerName: string; + grantCount: number; onUnpin: () => void; }) { return ( @@ -341,6 +363,10 @@ function PinnedCard({
{file.name}
+
+ {file.scanState === "approved" ? "Clean" : "Blocked"} + {grantCount} +
@@ -413,6 +439,10 @@ function filterFiles(files: FileObjectRecord[], section: Section, agents: AgentR return files; } +function fileGrantCount(fileId: string, grants: FileAccessGrantRecord[]): number { + return grants.filter((grant) => grant.fileId === fileId).length; +} + function isRecent(iso: string): boolean { const sevenDays = 7 * 24 * 60 * 60 * 1000; return Date.now() - new Date(iso).getTime() <= sevenDays; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 50bb97ce..95e4510e 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -43,6 +43,11 @@ export const PROVIDER_REQUEST_STATE = { FAILED: "failed", } as const; +export const FILE_SCAN_STATE = { + APPROVED: "approved", + BLOCKED: "blocked", +} as const; + export type ValueOf = T[keyof T]; export type TaskStatus = ValueOf; export type ApprovalState = ValueOf; @@ -51,6 +56,7 @@ export type TaskPriority = ValueOf; export type ProviderAuthType = ValueOf; export type SocialPlatform = ValueOf; export type ProviderRequestState = ValueOf; +export type FileScanState = ValueOf; export interface OrganizationRecord { id: string; @@ -261,10 +267,27 @@ export interface FileObjectRecord { ownerId: string | null; spaceId: string | null; pinned: boolean; + scanState: FileScanState; + scanLabelsJson: string; + scannedAt: string | null; createdAt: string; updatedAt: string | null; } +export interface FileAccessGrantRecord { + id: string; + orgId: string; + fileId: string; + grantedToType: "employee" | "agent" | "board" | "org_group"; + grantedToId: string; + grantedByType: "employee" | "agent" | "board" | "system"; + grantedById: string | null; + sourceMessageId: string | null; + sourceThreadId: string | null; + accessLevel: "read" | "manage"; + createdAt: string; +} + export interface ScheduledPostRecord { id: string; orgId: string; @@ -448,6 +471,7 @@ export interface AppSnapshot { chatPresence: ChatPresenceRecord[]; chatReadStates: ChatReadStateRecord[]; files: FileObjectRecord[]; + fileAccessGrants: FileAccessGrantRecord[]; scheduledPosts: ScheduledPostRecord[]; providers: ProviderRecord[]; providerCatalog: ProviderCatalogEntry[]; diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 85616593..ddd2d173 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); connection.close(); }); @@ -235,6 +235,8 @@ describe("domain services", () => { expect(state.chatMessages).toHaveLength(1); expect(state.chatThreads).toHaveLength(1); expect(state.chatAttachments).toHaveLength(1); + expect(state.files[0]?.scanState).toBe("approved"); + expect(state.fileAccessGrants[0]).toMatchObject({ grantedToType: "board", grantedToId: orgId, accessLevel: "read" }); expect(state.chatMentions[0]).toMatchObject({ targetType: "board", targetId: orgId }); expect(state.chatSlashInvocations[0]).toMatchObject({ command: "handoff" }); expect(state.chatPresence).toHaveLength(1); @@ -246,6 +248,114 @@ describe("domain services", () => { connection.close(); }); + test("enforces scanned file grants for agent handoff", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const chat = new ChatService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let spaceId = ""; + let adaId = ""; + let benId = ""; + let approvedFileId = ""; + let blockedFileId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + adaId = organization.createAgent({ + orgId, + name: "Ada", + role: "Release reviewer", + department: "Engineering", + modelProviderId: null, + modelId: null, + budgetCents: 10000, + managerAgentId: null, + }); + benId = organization.createAgent({ + orgId, + name: "Ben", + role: "Runtime owner", + department: "Engineering", + modelProviderId: null, + modelId: null, + budgetCents: 10000, + managerAgentId: null, + }); + spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" }); + approvedFileId = createId("fil"); + blockedFileId = createId("fil"); + const now = new Date().toISOString(); + connection.db.prepare(` + insert into file_objects( + id, org_id, name, kind, size_bytes, sha256, storage_path, + owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(approvedFileId, orgId, "release-notes.md", "md", 24, "sha-approved", "artifacts/release-notes.md", "agent", adaId, null, 0, "approved", "[]", now, now, now); + connection.db.prepare(` + insert into file_objects( + id, org_id, name, kind, size_bytes, sha256, storage_path, + owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(blockedFileId, orgId, "quarantine.bin", "file", 8, "sha-blocked", "artifacts/quarantine.bin", "agent", adaId, null, 0, "blocked", "[\"signature\"]", now, now, now); + }); + + expect(() => connection.transaction(() => chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "agent", + senderId: benId, + bodyMd: "Attach release notes", + attachmentFileIds: [approvedFileId], + }))).toThrow("Attachment file access is required."); + + connection.transaction(() => chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "agent", + senderId: adaId, + bodyMd: "/handoff @Ben release notes", + attachmentFileIds: [approvedFileId], + })); + + expect(() => connection.transaction(() => chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "agent", + senderId: adaId, + bodyMd: "/handoff @Ben blocked payload", + attachmentFileIds: [blockedFileId], + }))).toThrow("Attachment file is not approved for handoff."); + + connection.transaction(() => chat.sendMessage({ + orgId, + spaceId, + threadId: null, + senderType: "agent", + senderId: benId, + bodyMd: "Received release notes", + attachmentFileIds: [approvedFileId], + })); + + const state = snapshot.load(); + const benGrant = state.fileAccessGrants.find((grant) => grant.fileId === approvedFileId && grant.grantedToId === benId); + if (!benGrant) throw new Error("Agent handoff grant was not created."); + expect(benGrant).toMatchObject({ grantedToType: "agent", accessLevel: "read" }); + expect(state.chatMessages).toHaveLength(2); + expect(state.auditEvents.some((event) => event.entityType === "file_object" && event.action === "access_granted")).toBe(true); + connection.close(); + }); + test("creates approval and waiting presence from chat escalation", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index 74fe70a6..9f730293 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -121,6 +121,7 @@ describe("migration service", () => { create table skill_runtime_snapshots(id text primary key, agent_id text, run_id text); create table skill_catalog_tags(skill_id text, tag text, tag_type text); create table file_objects(id text primary key, org_id text, space_id text); + create table file_access_grants(id text primary key, org_id text, file_id text, source_message_id text, source_thread_id text); create table social_accounts(id text primary key, org_id text, authorized_employee_id text); create table social_account_grants(id text primary key, org_id text, social_account_id text, employee_id text); create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text); @@ -144,6 +145,7 @@ describe("migration service", () => { insert into chat_messages(id, org_id, space_id, thread_id) values ('msg-1', 'org-1', 'space-1', 'thread-1'); insert into chat_memberships(space_id, principal_type, principal_id) values ('space-1', 'employee', 'emp-1'); insert into chat_attachments(message_id, file_id) values ('msg-1', 'file-1'); + insert into file_access_grants(id, org_id, file_id, source_message_id, source_thread_id) values ('file-grant-1', 'org-1', 'file-1', 'msg-1', 'thread-1'); insert into chat_mentions(message_id, target_type, target_id) values ('msg-1', 'agent', 'agent-1'); insert into chat_slash_invocations(id, message_id) values ('slash-1', 'msg-1'); insert into chat_presence(principal_type, principal_id) values ('agent', 'agent-1'); @@ -176,6 +178,7 @@ describe("migration service", () => { expect(body.tableCounts.credentials).toBe(1); expect(body.tableCounts.skill_artifacts).toBe(1); expect(body.tableCounts.chat_read_state).toBe(1); + expect(body.tableCounts.file_access_grants).toBe(1); expect(body.tableCounts.provider_requests).toBe(1); expect(body.tableChecksums.audit_events).toHaveLength(64); expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);