Secure file handoff grants

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 16:03:08 -04:00
1 parent ff6fcf879d
commit eba83ef794
10 files changed
+437 -40

No files matched your search

@@ -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'));
`;
@@ -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;
@@ -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<string>();
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;
@@ -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<string>([`kind:${fileKind(sourcePath)}`]);
if (sizeBytes === 0) labels.add("empty");
if (sizeBytes > 100 * 1024 * 1024) labels.add("large");
return [...labels].sort();
}
@@ -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" },
@@ -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,
@@ -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<Section>({ kind: "all" });
const [view, setView] = useState<ViewMode>("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" ? (
<FilesTable
files={visibleFiles}
grants={fileAccessGrants}
employees={employees}
agents={agents}
onTogglePin={(file) => void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: !file.pinned }))}
@@ -105,6 +112,7 @@ export function FilesPage({ snapshot, actions }: PageProps) {
) : (
<FilesGrid
files={visibleFiles}
grants={fileAccessGrants}
employees={employees}
agents={agents}
onTogglePin={(file) => 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({
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr) 60px" }}
style={{ gridTemplateColumns: "minmax(0, 2.7fr) minmax(70px, 0.7fr) minmax(0, 1.2fr) minmax(94px, 0.8fr) minmax(88px, 0.8fr) 56px" }}
>
<div>Name</div><div>Size</div><div>Owner</div><div>Modified</div><div />
<div>Name</div><div>Size</div><div>Owner</div><div>Access</div><div>Modified</div><div />
</div>
{files.map((file, index) => (
<FileRow
key={file.id}
file={file}
owner={resolveOwnerName(file, employees, agents)}
grantCount={fileGrantCount(file.id, grants)}
last={index === files.length - 1}
onTogglePin={() => 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({
<div
className="table-row"
style={{
gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr) 60px",
gridTemplateColumns: "minmax(0, 2.7fr) minmax(70px, 0.7fr) minmax(0, 1.2fr) minmax(94px, 0.8fr) minmax(88px, 0.8fr) 56px",
borderBottom: last ? "none" : "1px solid var(--border-soft)",
}}
>
@@ -297,6 +313,10 @@ function FileRow({
<Avatar name={owner} size="sm" bot={isAgentOwner(file)} />
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{owner}</span>
</div>
<div className="row" style={{ gap: 6, minWidth: 0 }}>
<Pill tone={file.scanState === "approved" ? "green" : "red"}>{file.scanState === "approved" ? "Clean" : "Blocked"}</Pill>
<span className="mono" style={{ color: "var(--text-3)", fontSize: 11 }}>{grantCount}</span>
</div>
<div style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(file.updatedAt ?? file.createdAt)}</div>
<div style={{ textAlign: "right" }}>
<IconButton icon={file.pinned ? "pin" : "more"} label={file.pinned ? "Unpin" : "More"} onClick={onTogglePin} />
@@ -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({
<div style={{ fontSize: 13, fontWeight: 540, letterSpacing: "-0.005em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{file.name}
</div>
<div className="row" style={{ gap: 6 }}>
<Pill tone={file.scanState === "approved" ? "green" : "red"}>{file.scanState === "approved" ? "Clean" : "Blocked"}</Pill>
<span className="mono" style={{ color: "var(--text-3)", fontSize: 11 }}>{grantCount}</span>
</div>
<div className="row" style={{ gap: 8, marginTop: "auto", minWidth: 0 }}>
<Avatar name={ownerName} size="sm" bot={isAgentOwner(file)} />
<span style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
@@ -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;
+24
View File
@@ -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> = T[keyof T];
export type TaskStatus = ValueOf<typeof TASK_STATUS>;
export type ApprovalState = ValueOf<typeof APPROVAL_STATE>;
@@ -51,6 +56,7 @@ export type TaskPriority = ValueOf<typeof TASK_PRIORITY>;
export type ProviderAuthType = ValueOf<typeof PROVIDER_AUTH_TYPE>;
export type SocialPlatform = ValueOf<typeof SOCIAL_PLATFORM>;
export type ProviderRequestState = ValueOf<typeof PROVIDER_REQUEST_STATE>;
export type FileScanState = ValueOf<typeof FILE_SCAN_STATE>;
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[];
@@ -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);
@@ -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"]);