From eab282d8b19195c56df54e88352a0a3ddece4030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 23 May 2026 21:17:03 -0400 Subject: [PATCH] Add browser platform governance records --- .../026-browser-platform-governance.ts | 127 ++++++++++++++ AgentCompany-New/src/main/database/schema.ts | 2 + .../src/main/services/migration-service.ts | 10 ++ .../src/main/services/sdlc-service.ts | 2 +- .../src/main/services/snapshot-service.ts | 5 + .../services/social-browser-policy-service.ts | 165 ++++++++++++++++++ .../src/main/services/social-service.ts | 122 ++++++++----- AgentCompany-New/src/shared/domain.ts | 35 +--- AgentCompany-New/src/shared/social-domain.ts | 68 ++++++++ .../tests/unit/domain-services.test.ts | 2 +- .../tests/unit/migration-service.test.ts | 12 ++ .../tests/unit/social-service.test.ts | 64 ++++++- 12 files changed, 536 insertions(+), 78 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/026-browser-platform-governance.ts create mode 100644 AgentCompany-New/src/main/services/social-browser-policy-service.ts create mode 100644 AgentCompany-New/src/shared/social-domain.ts diff --git a/AgentCompany-New/src/main/database/migrations/026-browser-platform-governance.ts b/AgentCompany-New/src/main/database/migrations/026-browser-platform-governance.ts new file mode 100644 index 00000000..5c834812 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/026-browser-platform-governance.ts @@ -0,0 +1,127 @@ +export const browserPlatformGovernanceSchema = ` + create table if not exists browser_session_containers ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + social_account_id text not null references social_accounts(id) on delete cascade, + platform text not null, + partition_key text not null, + host_allowlist_json text not null, + permission_policy text not null, + sandbox_enabled integer not null, + created_at text not null, + last_used_at text, + unique(social_account_id) + ); + + create table if not exists browser_action_policies ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + social_account_id text not null references social_accounts(id) on delete cascade, + action_type text not null, + content_category text not null, + risk_level text not null, + approval_required integer not null, + approval_kind text, + created_at text not null, + unique(social_account_id, action_type, content_category) + ); + + create table if not exists social_approval_routes ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + social_account_id text not null references social_accounts(id) on delete cascade, + action_type text not null, + risk_level text not null, + approval_kind text not null, + route_to_type text not null, + route_to_id text, + created_at text not null, + unique(social_account_id, action_type, risk_level) + ); + + insert into browser_session_containers( + id, org_id, social_account_id, platform, partition_key, host_allowlist_json, + permission_policy, sandbox_enabled, created_at, last_used_at + ) + select + 'bsc_' || lower(hex(randomblob(16))), + org_id, + id, + platform, + 'persist:agentcompany-social-' || id, + case platform + when 'x' then '["x.com","twitter.com"]' + when 'linkedin' then '["linkedin.com","www.linkedin.com"]' + when 'youtube' then '["accounts.google.com","youtube.com","www.youtube.com"]' + when 'instagram' then '["instagram.com","www.instagram.com"]' + else '[]' + end, + 'deny_all', + 1, + datetime('now'), + null + from social_accounts + where not exists ( + select 1 from browser_session_containers + where browser_session_containers.social_account_id = social_accounts.id + ); + + insert into browser_action_policies( + id, org_id, social_account_id, action_type, content_category, risk_level, + approval_required, approval_kind, created_at + ) + select + 'bap_' || lower(hex(randomblob(16))), + social_accounts.org_id, + social_accounts.id, + policy.action_type, + policy.content_category, + policy.risk_level, + policy.approval_required, + policy.approval_kind, + datetime('now') + from social_accounts + join ( + select 'login' action_type, 'auth' content_category, 'low' risk_level, 0 approval_required, null approval_kind + union all select 'draft', 'content', 'low', 0, null + union all select 'approve', 'content', 'medium', 1, 'social_publish' + ) policy + where not exists ( + select 1 from browser_action_policies + where browser_action_policies.social_account_id = social_accounts.id + and browser_action_policies.action_type = policy.action_type + and browser_action_policies.content_category = policy.content_category + ); + + insert into social_approval_routes( + id, org_id, social_account_id, action_type, risk_level, approval_kind, + route_to_type, route_to_id, created_at + ) + select + 'sar_' || lower(hex(randomblob(16))), + org_id, + id, + 'approve', + 'medium', + 'social_publish', + 'authorized_employee', + authorized_employee_id, + datetime('now') + from social_accounts + where not exists ( + select 1 from social_approval_routes + where social_approval_routes.social_account_id = social_accounts.id + and social_approval_routes.action_type = 'approve' + and social_approval_routes.risk_level = 'medium' + ); + + create index if not exists idx_browser_session_containers_org + on browser_session_containers(org_id, social_account_id); + create index if not exists idx_browser_action_policies_org_account + on browser_action_policies(org_id, social_account_id, action_type); + create index if not exists idx_social_approval_routes_org_account + on social_approval_routes(org_id, social_account_id, action_type); + + insert into schema_migrations(version, name, applied_at) + values (26, 'browser_platform_governance', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 3838d169..c7135086 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -24,6 +24,7 @@ import { providerRateLimitObservationSchema } from "./migrations/022-provider-ra import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime"; import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request"; import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery"; +import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platform-governance"; interface Migration { version: number; @@ -57,6 +58,7 @@ const MIGRATIONS: Migration[] = [ { version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema }, { version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema }, { version: 25, name: "chat_message_delivery", sql: chatMessageDeliverySchema }, + { version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index 993af68e..256e66a7 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -64,6 +64,9 @@ const LEGACY_TABLES = [ "file_access_grants", "social_accounts", "social_account_grants", + "browser_session_containers", + "browser_action_policies", + "social_approval_routes", "browser_actions", "scheduled_posts", "policy_rules", @@ -214,6 +217,13 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "social_account_grants", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, { table: "social_account_grants", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" }, + { table: "browser_session_containers", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "browser_session_containers", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, + { table: "browser_action_policies", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "browser_action_policies", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, + { table: "social_approval_routes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "social_approval_routes", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, + { table: "social_approval_routes", column: "route_to_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_route_to_id" }, { table: "browser_actions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "browser_actions", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, { table: "browser_actions", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" }, diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index e38e62ee..124ac473 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -262,7 +262,7 @@ export class SdlcService { { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, { key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry }, { key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady }, - { key: "schema_version", passed: schemaVersion >= 25, value: schemaVersion }, + { key: "schema_version", passed: schemaVersion >= 26, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index ba660bb5..083137a6 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -116,6 +116,11 @@ export class SnapshotService { skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"), socialAccounts: list(this.db, "social_accounts", "created_at asc"), socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"), + browserSessionContainers: list(this.db, "browser_session_containers", "created_at asc") + .map((container) => ({ ...container, sandboxEnabled: toBool(container.sandboxEnabled) })), + browserActionPolicies: list(this.db, "browser_action_policies", "created_at asc") + .map((policy) => ({ ...policy, approvalRequired: toBool(policy.approvalRequired) })), + socialApprovalRoutes: list(this.db, "social_approval_routes", "created_at asc"), browserActions: list(this.db, "browser_actions", "created_at desc"), policyRules: list(this.db, "policy_rules", "created_at asc"), migrationReports: list(this.db, "migration_reports", "created_at desc"), diff --git a/AgentCompany-New/src/main/services/social-browser-policy-service.ts b/AgentCompany-New/src/main/services/social-browser-policy-service.ts new file mode 100644 index 00000000..95ba3168 --- /dev/null +++ b/AgentCompany-New/src/main/services/social-browser-policy-service.ts @@ -0,0 +1,165 @@ +import type { DatabaseSync } from "node:sqlite"; +import { SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain"; +import { createId } from "../../shared/ids"; + +export const SOCIAL_BROWSER_ACTION = { + LOGIN: "login", + DRAFT: "draft", + APPROVE: "approve", +} as const; + +export type SocialBrowserAction = typeof SOCIAL_BROWSER_ACTION[keyof typeof SOCIAL_BROWSER_ACTION]; + +const PLATFORM_HOSTS: Record = { + [SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"], + [SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"], + [SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"], + [SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"], +}; + +const ACTION_POLICIES: ReadonlyArray<{ + actionType: SocialBrowserAction; + contentCategory: string; + riskLevel: RiskLevel; + approvalRequired: boolean; + approvalKind: string | null; +}> = [ + { actionType: SOCIAL_BROWSER_ACTION.LOGIN, contentCategory: "auth", riskLevel: "low", approvalRequired: false, approvalKind: null }, + { actionType: SOCIAL_BROWSER_ACTION.DRAFT, contentCategory: "content", riskLevel: "low", approvalRequired: false, approvalKind: null }, + { actionType: SOCIAL_BROWSER_ACTION.APPROVE, contentCategory: "content", riskLevel: "medium", approvalRequired: true, approvalKind: "social_publish" }, +]; + +export interface BrowserSessionContainerRow { + id: string; + partition_key: string; + host_allowlist_json: string; + permission_policy: string; + sandbox_enabled: number; +} + +export interface BrowserActionPolicyRow { + id: string; + action_type: SocialBrowserAction; + risk_level: RiskLevel; + approval_required: number; + approval_kind: string | null; +} + +export interface SocialApprovalRouteRow { + id: string; + approval_kind: string; + route_to_type: string; + route_to_id: string | null; +} + +export class SocialBrowserPolicyService { + constructor(private readonly db: DatabaseSync) {} + + seedAccountControls(input: { + orgId: string; + socialAccountId: string; + platform: SocialPlatform; + authorizedEmployeeId: string | null; + createdAt: string; + }): void { + this.db.prepare(` + insert or ignore into browser_session_containers( + id, org_id, social_account_id, platform, partition_key, host_allowlist_json, + permission_policy, sandbox_enabled, created_at, last_used_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, null) + `).run( + createId("bsc"), + input.orgId, + input.socialAccountId, + input.platform, + `persist:agentcompany-social-${input.socialAccountId}`, + JSON.stringify(PLATFORM_HOSTS[input.platform]), + "deny_all", + 1, + input.createdAt, + ); + for (const policy of ACTION_POLICIES) { + this.db.prepare(` + insert or ignore into browser_action_policies( + id, org_id, social_account_id, action_type, content_category, risk_level, + approval_required, approval_kind, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + createId("bap"), + input.orgId, + input.socialAccountId, + policy.actionType, + policy.contentCategory, + policy.riskLevel, + policy.approvalRequired ? 1 : 0, + policy.approvalKind, + input.createdAt, + ); + } + this.db.prepare(` + insert or ignore into social_approval_routes( + id, org_id, social_account_id, action_type, risk_level, approval_kind, + route_to_type, route_to_id, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + createId("sar"), + input.orgId, + input.socialAccountId, + SOCIAL_BROWSER_ACTION.APPROVE, + "medium", + "social_publish", + "authorized_employee", + input.authorizedEmployeeId, + input.createdAt, + ); + } + + requireSessionContainer(orgId: string, socialAccountId: string): BrowserSessionContainerRow { + const row = this.db.prepare(` + select id, partition_key, host_allowlist_json, permission_policy, sandbox_enabled + from browser_session_containers + where org_id = ? and social_account_id = ? + `).get(orgId, socialAccountId) as BrowserSessionContainerRow | undefined; + if (!row) throw new Error("Browser session container is required."); + this.parseHostAllowlist(row.host_allowlist_json); + if (row.permission_policy !== "deny_all") throw new Error("Browser permission policy is invalid."); + if (row.sandbox_enabled !== 1) throw new Error("Browser sandbox policy is required."); + return row; + } + + allowedHosts(container: BrowserSessionContainerRow): string[] { + return this.parseHostAllowlist(container.host_allowlist_json); + } + + markSessionUsed(containerId: string, usedAt: string): void { + this.db.prepare("update browser_session_containers set last_used_at = ? where id = ?").run(usedAt, containerId); + } + + requireActionPolicy(orgId: string, socialAccountId: string, actionType: SocialBrowserAction): BrowserActionPolicyRow { + const row = this.db.prepare(` + select id, action_type, risk_level, approval_required, approval_kind + from browser_action_policies + where org_id = ? and social_account_id = ? and action_type = ? + `).get(orgId, socialAccountId, actionType) as BrowserActionPolicyRow | undefined; + if (!row) throw new Error("Browser action policy is required."); + return row; + } + + requireApprovalRoute(orgId: string, socialAccountId: string, actionType: SocialBrowserAction, riskLevel: RiskLevel): SocialApprovalRouteRow { + const row = this.db.prepare(` + select id, approval_kind, route_to_type, route_to_id + from social_approval_routes + where org_id = ? and social_account_id = ? and action_type = ? and risk_level = ? + `).get(orgId, socialAccountId, actionType, riskLevel) as SocialApprovalRouteRow | undefined; + if (!row) throw new Error("Social approval route is required."); + return row; + } + + private parseHostAllowlist(raw: string): string[] { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === "string" && item.trim())) { + throw new Error("Browser host allowlist is invalid."); + } + return parsed.map((item) => item.toLowerCase()); + } +} diff --git a/AgentCompany-New/src/main/services/social-service.ts b/AgentCompany-New/src/main/services/social-service.ts index ea0c0439..c018d946 100644 --- a/AgentCompany-New/src/main/services/social-service.ts +++ b/AgentCompany-New/src/main/services/social-service.ts @@ -6,10 +6,17 @@ import type { LaunchSocialLoginInput, SetScheduledPostStateInput, } from "../../shared/commands"; -import { APPROVAL_STATE, RISK_LEVEL, SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain"; +import { APPROVAL_STATE, RISK_LEVEL, ROUTE_TO_TYPE, SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { ApprovalService } from "./approval-service"; import { AuditService } from "./audit-service"; +import { + SOCIAL_BROWSER_ACTION, + SocialBrowserPolicyService, + type BrowserActionPolicyRow, + type SocialApprovalRouteRow, + type SocialBrowserAction, +} from "./social-browser-policy-service"; const PLATFORM_LOGIN_URL: Record = { [SOCIAL_PLATFORM.X]: "https://x.com/i/flow/login", @@ -18,34 +25,19 @@ const PLATFORM_LOGIN_URL: Record = { [SOCIAL_PLATFORM.INSTAGRAM]: "https://www.instagram.com/accounts/login/", }; -const ALLOWED_HOSTS: Record = { - [SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"], - [SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"], - [SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"], - [SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"], -}; - -const SOCIAL_GRANT_ACTION = { - LOGIN: "login", - DRAFT: "draft", - APPROVE: "approve", -} as const; - -type SocialGrantAction = typeof SOCIAL_GRANT_ACTION[keyof typeof SOCIAL_GRANT_ACTION]; - -const STATE_GRANT_ACTION: Record = { - draft: SOCIAL_GRANT_ACTION.DRAFT, - scheduled: SOCIAL_GRANT_ACTION.APPROVE, - published: SOCIAL_GRANT_ACTION.APPROVE, - cancelled: SOCIAL_GRANT_ACTION.APPROVE, +const STATE_GRANT_ACTION: Record = { + draft: SOCIAL_BROWSER_ACTION.DRAFT, + scheduled: SOCIAL_BROWSER_ACTION.APPROVE, + published: SOCIAL_BROWSER_ACTION.APPROVE, + cancelled: SOCIAL_BROWSER_ACTION.APPROVE, }; const GRANT_SCOPE = { - actions: [SOCIAL_GRANT_ACTION.LOGIN, SOCIAL_GRANT_ACTION.DRAFT, SOCIAL_GRANT_ACTION.APPROVE], + actions: [SOCIAL_BROWSER_ACTION.LOGIN, SOCIAL_BROWSER_ACTION.DRAFT, SOCIAL_BROWSER_ACTION.APPROVE], riskCeiling: RISK_LEVEL.MEDIUM, } as const; -const ACTION_REQUIRED_RISK: Record = { +const ACTION_REQUIRED_RISK: Record = { login: RISK_LEVEL.LOW, draft: RISK_LEVEL.LOW, approve: RISK_LEVEL.MEDIUM, @@ -83,15 +75,18 @@ interface GrantScope { export class SocialService { private readonly audit: AuditService; private readonly approvals: ApprovalService; + private readonly browserPolicy: SocialBrowserPolicyService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.approvals = new ApprovalService(db); + this.browserPolicy = new SocialBrowserPolicyService(db); } createScheduledPost(input: CreateScheduledPostInput): string { this.requireAccount(input.orgId, input.socialAccountId); - this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_GRANT_ACTION.DRAFT); + this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.DRAFT); + this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_BROWSER_ACTION.DRAFT); const body = this.requireText(input.bodyMd, "Post body is required."); const scheduledFor = this.requireSchedule(input.scheduledFor); const id = createId("spost"); @@ -127,9 +122,11 @@ export class SocialService { setScheduledPostState(input: SetScheduledPostStateInput): void { const post = this.requireScheduledPost(input.orgId, input.scheduledPostId); - this.requireGrant(input.orgId, post.social_account_id, input.employeeId, STATE_GRANT_ACTION[input.state]); + const action = STATE_GRANT_ACTION[input.state]; + const policy = this.browserPolicy.requireActionPolicy(input.orgId, post.social_account_id, action); + this.requireGrant(input.orgId, post.social_account_id, input.employeeId, action); this.assertStateTransition(post, input.state); - const approvalId = this.resolveApprovalForState(input, post); + const approvalId = this.resolveApprovalForState(input, post, policy); const now = new Date().toISOString(); const result = this.db.prepare(` update scheduled_posts set state = ?, approval_id = ?, updated_at = ? @@ -171,6 +168,13 @@ export class SocialService { input.authorizedEmployeeId, now, ); + this.browserPolicy.seedAccountControls({ + orgId: input.orgId, + socialAccountId: id, + platform: input.platform, + authorizedEmployeeId: input.authorizedEmployeeId ?? null, + createdAt: now, + }); if (input.authorizedEmployeeId) { this.db.prepare(` insert into social_account_grants(id, org_id, social_account_id, employee_id, role, scope_json, created_at) @@ -199,9 +203,10 @@ export class SocialService { launchLoginWindow(input: LaunchSocialLoginInput): void { const account = this.requireAccount(input.orgId, input.socialAccountId); - this.requireGrant(input.orgId, input.socialAccountId, input.employeeId, SOCIAL_GRANT_ACTION.LOGIN); - const partition = `persist:agentcompany-social-${account.id}`; - const socialSession = session.fromPartition(partition); + const container = this.browserPolicy.requireSessionContainer(input.orgId, input.socialAccountId); + this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.LOGIN); + this.requireGrant(input.orgId, input.socialAccountId, input.employeeId, SOCIAL_BROWSER_ACTION.LOGIN); + const socialSession = session.fromPartition(container.partition_key); socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); socialSession.setPermissionCheckHandler(() => false); @@ -213,15 +218,17 @@ export class SocialService { session: socialSession, nodeIntegration: false, contextIsolation: true, - sandbox: true, + sandbox: container.sandbox_enabled === 1, }, }); - this.attachNavigationPolicy(win, account.platform); + this.attachNavigationPolicy(win, this.browserPolicy.allowedHosts(container)); win.loadURL(PLATFORM_LOGIN_URL[account.platform]); + const now = new Date().toISOString(); + this.browserPolicy.markSessionUsed(container.id, now); this.db.prepare(` insert into browser_actions(id, org_id, social_account_id, action_type, policy_decision, evidence_file_id, created_at) values (?, ?, ?, ?, ?, ?, ?) - `).run(createId("bact"), account.org_id, account.id, "login_window_opened", "allowed_human_login", null, new Date().toISOString()); + `).run(createId("bact"), account.org_id, account.id, "login_window_opened", "allowed_human_login", null, now); this.audit.record({ orgId: account.org_id, entityType: "social_account", @@ -233,10 +240,10 @@ export class SocialService { }); } - private attachNavigationPolicy(win: BrowserWindow, platform: SocialPlatform): void { + private attachNavigationPolicy(win: BrowserWindow, allowedHosts: string[]): void { win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); const guard = (event: Electron.Event, url: string) => { - if (!this.isAllowedNavigation(platform, url)) event.preventDefault(); + if (!this.isAllowedNavigation(allowedHosts, url)) event.preventDefault(); }; win.webContents.on("will-navigate", guard); win.webContents.on("will-redirect", guard); @@ -256,7 +263,7 @@ export class SocialService { return post; } - private requireGrant(orgId: string, socialAccountId: string, employeeId: string, action: SocialGrantAction): void { + private requireGrant(orgId: string, socialAccountId: string, employeeId: string, action: SocialBrowserAction): void { this.assertEmployee(orgId, employeeId, "Social account grant is required."); const row = this.db.prepare(` select scope_json from social_account_grants @@ -270,27 +277,52 @@ export class SocialService { } } - private resolveApprovalForState(input: SetScheduledPostStateInput, post: ScheduledPostRow): string | null { + private resolveApprovalForState(input: SetScheduledPostStateInput, post: ScheduledPostRow, policy: BrowserActionPolicyRow): string | null { if (input.state === "scheduled") { - return post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post.body_md); + return policy.approval_required === 1 + ? post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post, policy) + : post.approval_id; } if (input.state === "published") { - this.assertApprovalApproved(input.orgId, post.approval_id); + if (policy.approval_required === 1) this.assertApprovalApproved(input.orgId, post.approval_id); return post.approval_id; } return post.approval_id; } - private createPublishApproval(orgId: string, employeeId: string, body: string): string { - return this.approvals.create({ + private createPublishApproval(orgId: string, employeeId: string, post: ScheduledPostRow, policy: BrowserActionPolicyRow): string { + const route = this.browserPolicy.requireApprovalRoute(orgId, post.social_account_id, policy.action_type, policy.risk_level); + const approvalId = this.approvals.create({ orgId, - kind: "social_publish", + kind: route.approval_kind, title: "Approve social post", - details: body, - risk: RISK_LEVEL.MEDIUM, + details: post.body_md, + risk: policy.risk_level, requestedByType: "employee", requestedById: employeeId, }); + this.assignSocialApprovalRoute(orgId, approvalId, route); + return approvalId; + } + + private assignSocialApprovalRoute(orgId: string, approvalId: string, route: SocialApprovalRouteRow): void { + if (route.route_to_type !== "authorized_employee") throw new Error("Social approval route target type is invalid."); + if (!route.route_to_id) throw new Error("Social approval route target is required."); + this.assertEmployee(orgId, route.route_to_id, "Social approval route employee was not found."); + this.db.prepare(` + insert into approval_assignments(id, org_id, approval_id, assignee_type, assignee_id, routing_rule_id, created_at) + values (?, ?, ?, ?, ?, null, ?) + on conflict(approval_id, assignee_type, assignee_id) do nothing + `).run(createId("aas"), orgId, approvalId, ROUTE_TO_TYPE.EMPLOYEE, route.route_to_id, new Date().toISOString()); + this.audit.record({ + orgId, + entityType: "approval", + entityId: approvalId, + actorType: "system", + actorId: null, + action: "social_route_assigned", + payload: { routeId: route.id, routeToType: route.route_to_type, routeToId: route.route_to_id }, + }); } private assertApprovalApproved(orgId: string, approvalId: string | null): void { @@ -345,11 +377,11 @@ export class SocialService { return scheduledFor; } - private isAllowedNavigation(platform: SocialPlatform, url: string): boolean { + private isAllowedNavigation(allowedHosts: string[], url: string): boolean { const parsed = new URL(url); if (parsed.protocol !== "https:") return false; const host = parsed.hostname.toLowerCase(); - return ALLOWED_HOSTS[platform].some((entry) => host === entry || host.endsWith(`.${entry}`)); + return allowedHosts.some((entry) => host === entry || host.endsWith(`.${entry}`)); } } diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 9aec2d1c..fba2cbb0 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -10,6 +10,7 @@ import type { ProviderRequestRecord } from "./provider-request-domain"; import type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain"; import type { RollbackSnapshotRecord } from "./rollback-domain"; import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-domain"; +import type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain"; export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain"; export type { AuditReplayRunRecord } from "./audit-domain"; export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain"; @@ -23,6 +24,7 @@ export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } export type { RollbackSnapshotRecord } from "./rollback-domain"; export { EVAL_SUITE_KEY, PR_BOT_GATE_KEY, SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE, SDLC_RUN_STATE, type EvalCaseRecord, type EvalRunRecord, type EvalSuiteKey, type EvalSuiteRecord, type PrBotGateKey, type PrBotGateRecord, type SdlcReviewAgentKey, type SdlcReviewAgentRecord, type SdlcReviewRunRecord, type SdlcReviewTargetType, type SdlcRunState } from "./sdlc-domain"; export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain"; +export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain"; export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const; export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const; export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const; @@ -376,36 +378,6 @@ export interface SkillRuntimeSnapshotRecord { createdAt: string; } -export interface SocialAccountRecord { - id: string; - orgId: string; - platform: SocialPlatform; - handle: string; - displayName: string; - authorizedEmployeeId: string | null; - createdAt: string; -} - -export interface SocialAccountGrantRecord { - id: string; - orgId: string; - socialAccountId: string; - employeeId: string; - role: string; - scopeJson: string; - createdAt: string; -} - -export interface BrowserActionRecord { - id: string; - orgId: string; - socialAccountId: string; - actionType: string; - policyDecision: string; - evidenceFileId: string | null; - createdAt: string; -} - export interface AuditEventRecord { id: string; orgId: string; @@ -473,6 +445,9 @@ export interface AppSnapshot { skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[]; socialAccounts: SocialAccountRecord[]; socialAccountGrants: SocialAccountGrantRecord[]; + browserSessionContainers: BrowserSessionContainerRecord[]; + browserActionPolicies: BrowserActionPolicyRecord[]; + socialApprovalRoutes: SocialApprovalRouteRecord[]; browserActions: BrowserActionRecord[]; policyRules: PolicyRuleRecord[]; migrationReports: MigrationReportRecord[]; diff --git a/AgentCompany-New/src/shared/social-domain.ts b/AgentCompany-New/src/shared/social-domain.ts new file mode 100644 index 00000000..04cb0cb0 --- /dev/null +++ b/AgentCompany-New/src/shared/social-domain.ts @@ -0,0 +1,68 @@ +import type { SocialPlatform } from "./domain"; + +export interface SocialAccountRecord { + id: string; + orgId: string; + platform: SocialPlatform; + handle: string; + displayName: string; + authorizedEmployeeId: string | null; + createdAt: string; +} + +export interface SocialAccountGrantRecord { + id: string; + orgId: string; + socialAccountId: string; + employeeId: string; + role: string; + scopeJson: string; + createdAt: string; +} + +export interface BrowserSessionContainerRecord { + id: string; + orgId: string; + socialAccountId: string; + platform: SocialPlatform; + partitionKey: string; + hostAllowlistJson: string; + permissionPolicy: string; + sandboxEnabled: boolean; + createdAt: string; + lastUsedAt: string | null; +} + +export interface BrowserActionPolicyRecord { + id: string; + orgId: string; + socialAccountId: string; + actionType: string; + contentCategory: string; + riskLevel: string; + approvalRequired: boolean; + approvalKind: string | null; + createdAt: string; +} + +export interface SocialApprovalRouteRecord { + id: string; + orgId: string; + socialAccountId: string; + actionType: string; + riskLevel: string; + approvalKind: string; + routeToType: string; + routeToId: string | null; + createdAt: string; +} + +export interface BrowserActionRecord { + id: string; + orgId: string; + socialAccountId: string; + actionType: string; + policyDecision: string; + evidenceFileId: string | null; + createdAt: string; +} diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 121e2bb2..0e07b9d0 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, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index 7c7a2e56..b2160fc4 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -146,6 +146,9 @@ describe("migration service", () => { 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_session_containers(id text primary key, org_id text, social_account_id text); + create table browser_action_policies(id text primary key, org_id text, social_account_id text); + create table social_approval_routes(id text primary key, org_id text, social_account_id text, route_to_id text); create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text); create table scheduled_posts(id text primary key, org_id text, social_account_id text, approval_id text, created_by_employee_id text); create table policy_rules(id text primary key, org_id text); @@ -211,6 +214,9 @@ describe("migration service", () => { insert into skill_catalog_tags(skill_id, tag, tag_type) values ('skill-1', 'engineering', 'department'); insert into social_accounts(id, org_id, authorized_employee_id) values ('social-1', 'org-1', 'emp-1'); insert into social_account_grants(id, org_id, social_account_id, employee_id) values ('grant-1', 'org-1', 'social-1', 'emp-1'); + insert into browser_session_containers(id, org_id, social_account_id) values ('container-1', 'org-1', 'social-1'); + insert into browser_action_policies(id, org_id, social_account_id) values ('browser-policy-1', 'org-1', 'social-1'); + insert into social_approval_routes(id, org_id, social_account_id, route_to_id) values ('social-route-1', 'org-1', 'social-1', 'emp-1'); insert into browser_actions(id, org_id, social_account_id, evidence_file_id) values ('browser-1', 'org-1', 'social-1', 'file-1'); insert into scheduled_posts(id, org_id, social_account_id, approval_id, created_by_employee_id) values ('post-1', 'org-1', 'social-1', 'approval-1', 'emp-1'); insert into policy_rules(id, org_id) values ('policy-1', 'org-1'); @@ -247,6 +253,9 @@ describe("migration service", () => { expect(body.tableCounts.provider_requests).toBe(1); expect(body.tableCounts.provider_usage_windows).toBe(1); expect(body.tableCounts.provider_rate_limit_observations).toBe(1); + expect(body.tableCounts.browser_session_containers).toBe(1); + expect(body.tableCounts.browser_action_policies).toBe(1); + expect(body.tableCounts.social_approval_routes).toBe(1); expect(body.tableCounts.compliance_exports).toBe(1); expect(body.tableCounts.audit_replay_runs).toBe(1); expect(body.tableCounts.rollback_snapshots).toBe(1); @@ -266,6 +275,9 @@ describe("migration service", () => { expect(body.idMaps.rollback_snapshots).toEqual(["rollback-1"]); expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]); expect(body.idMaps.provider_rate_limit_observations).toEqual(["quota-1"]); + expect(body.idMaps.browser_session_containers).toEqual(["container-1"]); + expect(body.idMaps.browser_action_policies).toEqual(["browser-policy-1"]); + expect(body.idMaps.social_approval_routes).toEqual(["social-route-1"]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/social-service.test.ts b/AgentCompany-New/tests/unit/social-service.test.ts index a6a37046..6371cf95 100644 --- a/AgentCompany-New/tests/unit/social-service.test.ts +++ b/AgentCompany-New/tests/unit/social-service.test.ts @@ -8,7 +8,7 @@ import { FileService } from "../../src/main/services/file-service"; import { OrganizationService } from "../../src/main/services/organization-service"; import { SnapshotService } from "../../src/main/services/snapshot-service"; import { SocialService } from "../../src/main/services/social-service"; -import { APPROVAL_STATE, SOCIAL_PLATFORM } from "../../src/shared/domain"; +import { APPROVAL_STATE, ROUTE_TO_TYPE, SOCIAL_PLATFORM } from "../../src/shared/domain"; import { createId } from "../../src/shared/ids"; function createConnection(): DatabaseConnection { @@ -62,6 +62,25 @@ describe("social service", () => { const grantScope = JSON.parse(state.socialAccountGrants[0]!.scopeJson) as { actions: string[]; riskCeiling: string }; expect(state.socialAccounts).toHaveLength(1); expect(state.socialAccountGrants[0]?.employeeId).toBe(employeeId); + expect(state.browserSessionContainers[0]).toMatchObject({ + socialAccountId: state.socialAccounts[0]?.id, + platform: SOCIAL_PLATFORM.X, + permissionPolicy: "deny_all", + sandboxEnabled: true, + }); + expect(JSON.parse(state.browserSessionContainers[0]!.hostAllowlistJson)).toEqual(["x.com", "twitter.com"]); + expect(state.browserActionPolicies.map((policy) => policy.actionType).sort()).toEqual(["approve", "draft", "login"]); + expect(state.browserActionPolicies.find((policy) => policy.actionType === "approve")).toMatchObject({ + approvalRequired: true, + approvalKind: "social_publish", + riskLevel: "medium", + }); + expect(state.socialApprovalRoutes[0]).toMatchObject({ + socialAccountId: state.socialAccounts[0]?.id, + actionType: "approve", + approvalKind: "social_publish", + routeToId: employeeId, + }); expect(state.auditEvents.find((event) => event.entityType === "social_account")?.actorId).toBe(employeeId); expect(grantScope.actions).toEqual(["login", "draft", "approve"]); expect(grantScope.riskCeiling).toBe("medium"); @@ -137,6 +156,49 @@ describe("social service", () => { connection.close(); }); + test("assigns social publish approvals to the account authorizer", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const social = new SocialService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let authorizerId = ""; + let approvalId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + const creatorId = firstEmployeeId(connection, orgId); + authorizerId = createEmployee(connection, orgId, "Riley Chen"); + const socialAccountId = social.createAccount({ + orgId, + platform: SOCIAL_PLATFORM.INSTAGRAM, + handle: "acme", + displayName: "Acme", + createdByEmployeeId: creatorId, + authorizedEmployeeId: authorizerId, + }); + const postId = social.createScheduledPost({ + orgId, + socialAccountId, + bodyMd: "Launch update.", + scheduledFor: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + createdByEmployeeId: authorizerId, + }); + social.setScheduledPostState({ orgId, scheduledPostId: postId, state: "scheduled", employeeId: authorizerId }); + approvalId = (connection.db.prepare("select approval_id from scheduled_posts where id = ?") + .get(postId) as { approval_id: string }).approval_id; + }); + + const state = snapshot.load(); + expect(state.approvalAssignments).toContainEqual(expect.objectContaining({ + approvalId, + assigneeType: ROUTE_TO_TYPE.EMPLOYEE, + assigneeId: authorizerId, + routingRuleId: null, + })); + connection.close(); + }); + test("rejects social actions without account grants", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);