Secure file handoff grants
This commit is contained in:
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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
Reference in new issue
Block a user