diff --git a/AgentCompany-New/src/main/database/migrations/032-browser-action-metadata.ts b/AgentCompany-New/src/main/database/migrations/032-browser-action-metadata.ts new file mode 100644 index 00000000..95096c19 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/032-browser-action-metadata.ts @@ -0,0 +1,6 @@ +export const browserActionMetadataSchema = ` + alter table browser_actions add column metadata_json text not null default '{}'; + + insert into schema_migrations(version, name, applied_at) + values (32, 'browser_action_metadata', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 5828b629..368cf87b 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -30,6 +30,7 @@ import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutove import { fileHandoffRecordsSchema } from "./migrations/029-file-handoff-records"; import { approvalSourceRefsSchema } from "./migrations/030-approval-source-refs"; import { providerRequestWorkspaceSchema } from "./migrations/031-provider-request-workspace"; +import { browserActionMetadataSchema } from "./migrations/032-browser-action-metadata"; interface Migration { version: number; @@ -69,6 +70,7 @@ const MIGRATIONS: Migration[] = [ { version: 29, name: "file_handoff_records", sql: fileHandoffRecordsSchema }, { version: 30, name: "approval_source_refs", sql: approvalSourceRefsSchema }, { version: 31, name: "provider_request_workspace", sql: providerRequestWorkspaceSchema }, + { version: 32, name: "browser_action_metadata", sql: browserActionMetadataSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/social-service.ts b/AgentCompany-New/src/main/services/social-service.ts index 0bb3e91e..3474cbb2 100644 --- a/AgentCompany-New/src/main/services/social-service.ts +++ b/AgentCompany-New/src/main/services/social-service.ts @@ -73,13 +73,26 @@ interface GrantScope { riskCeiling: RiskLevel; } +export interface SocialLoginWindowInput { + title: string; + loginUrl: string; + partitionKey: string; + allowedHosts: string[]; + sandboxEnabled: boolean; +} + +export type SocialLoginWindowLauncher = (input: SocialLoginWindowInput) => void; + export class SocialService { private readonly audit: AuditService; private readonly approvals: ApprovalService; private readonly socialAudit: SocialAuditService; private readonly browserPolicy: SocialBrowserPolicyService; - constructor(private readonly db: DatabaseSync) { + constructor( + private readonly db: DatabaseSync, + private readonly launchSocialLoginWindow: SocialLoginWindowLauncher = openSocialLoginWindow, + ) { this.audit = new AuditService(db); this.approvals = new ApprovalService(db); this.socialAudit = new SocialAuditService(db); @@ -245,30 +258,34 @@ export class SocialService { 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); - - const win = new BrowserWindow({ - width: 1280, - height: 860, - title: `${account.handle}`, - webPreferences: { - session: socialSession, - nodeIntegration: false, - contextIsolation: true, - sandbox: container.sandbox_enabled === 1, - }, + const allowedHosts = this.browserPolicy.allowedHosts(container); + const loginUrl = PLATFORM_LOGIN_URL[account.platform]; + this.launchSocialLoginWindow({ + title: account.handle, + loginUrl, + partitionKey: container.partition_key, + allowedHosts, + sandboxEnabled: container.sandbox_enabled === 1, }); - 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); const browserActionId = createId("bact"); + const metadata = loginEvidenceMetadata({ + platform: account.platform, + loginUrl, + containerId: container.id, + partitionKey: container.partition_key, + hostAllowlist: allowedHosts, + permissionPolicy: container.permission_policy, + sandboxEnabled: container.sandbox_enabled === 1, + actorId: input.employeeId, + }); this.db.prepare(` - insert into browser_actions(id, org_id, social_account_id, action_type, policy_decision, evidence_file_id, created_at) - values (?, ?, ?, ?, ?, ?, ?) - `).run(browserActionId, account.org_id, account.id, "login_window_opened", "allowed_human_login", null, now); + insert into browser_actions( + id, org_id, social_account_id, action_type, policy_decision, + evidence_file_id, metadata_json, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `).run(browserActionId, account.org_id, account.id, "login_window_opened", "allowed_human_login", null, JSON.stringify(metadata), now); this.audit.record({ orgId: account.org_id, entityType: "social_account", @@ -287,20 +304,11 @@ export class SocialService { actorType: "employee", actorId: input.employeeId, policyDecision: "allowed_human_login", - metadata: { platform: account.platform, containerId: container.id }, + metadata, createdAt: now, }); } - private attachNavigationPolicy(win: BrowserWindow, allowedHosts: string[]): void { - win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); - const guard = (event: Electron.Event, url: string) => { - if (!this.isAllowedNavigation(allowedHosts, url)) event.preventDefault(); - }; - win.webContents.on("will-navigate", guard); - win.webContents.on("will-redirect", guard); - } - private requireAccount(orgId: string, accountId: string): SocialAccountRow { const account = this.db.prepare("select * from social_accounts where id = ? and org_id = ?") .get(accountId, orgId) as SocialAccountRow | undefined; @@ -429,12 +437,6 @@ export class SocialService { return scheduledFor; } - private isAllowedNavigation(allowedHosts: string[], url: string): boolean { - const parsed = new URL(url); - if (parsed.protocol !== "https:") return false; - const host = parsed.hostname.toLowerCase(); - return allowedHosts.some((entry) => host === entry || host.endsWith(`.${entry}`)); - } } function parseGrantScope(raw: string): GrantScope { @@ -464,3 +466,62 @@ function policyDecisionForState(state: SetScheduledPostStateInput["state"], appr if (state === "published" && approvalRequired) return "allowed_after_approval"; return "allowed_by_policy"; } + +function loginEvidenceMetadata(input: { + platform: SocialPlatform; + loginUrl: string; + containerId: string; + partitionKey: string; + hostAllowlist: string[]; + permissionPolicy: string; + sandboxEnabled: boolean; + actorId: string; +}): Record { + return { + platform: input.platform, + loginUrl: input.loginUrl, + containerId: input.containerId, + partitionKey: input.partitionKey, + hostAllowlist: input.hostAllowlist, + permissionPolicy: input.permissionPolicy, + sandboxEnabled: input.sandboxEnabled, + actorType: "employee", + actorId: input.actorId, + }; +} + +function openSocialLoginWindow(input: SocialLoginWindowInput): void { + const socialSession = session.fromPartition(input.partitionKey); + socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + socialSession.setPermissionCheckHandler(() => false); + + const win = new BrowserWindow({ + width: 1280, + height: 860, + title: input.title, + webPreferences: { + session: socialSession, + nodeIntegration: false, + contextIsolation: true, + sandbox: input.sandboxEnabled, + }, + }); + attachNavigationPolicy(win, input.allowedHosts); + win.loadURL(input.loginUrl); +} + +function attachNavigationPolicy(win: BrowserWindow, allowedHosts: string[]): void { + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + const guard = (event: Electron.Event, url: string) => { + if (!isAllowedNavigation(allowedHosts, url)) event.preventDefault(); + }; + win.webContents.on("will-navigate", guard); + win.webContents.on("will-redirect", guard); +} + +function isAllowedNavigation(allowedHosts: string[], url: string): boolean { + const parsed = new URL(url); + if (parsed.protocol !== "https:") return false; + const host = parsed.hostname.toLowerCase(); + return allowedHosts.some((entry) => host === entry || host.endsWith(`.${entry}`)); +} diff --git a/AgentCompany-New/src/shared/social-domain.ts b/AgentCompany-New/src/shared/social-domain.ts index acb9544a..03d3b92f 100644 --- a/AgentCompany-New/src/shared/social-domain.ts +++ b/AgentCompany-New/src/shared/social-domain.ts @@ -64,6 +64,7 @@ export interface BrowserActionRecord { actionType: string; policyDecision: string; evidenceFileId: string | null; + metadataJson: string; createdAt: string; } diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 8965e03e..585355e4 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, 26, 27, 28, 29, 30, 31]); + 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, 27, 28, 29, 30, 31, 32]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/social-service.test.ts b/AgentCompany-New/tests/unit/social-service.test.ts index 71a07f3b..7e224ce2 100644 --- a/AgentCompany-New/tests/unit/social-service.test.ts +++ b/AgentCompany-New/tests/unit/social-service.test.ts @@ -7,7 +7,7 @@ import { ApprovalService } from "../../src/main/services/approval-service"; 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 { SocialService, type SocialLoginWindowInput } from "../../src/main/services/social-service"; import { APPROVAL_STATE, ROUTE_TO_TYPE, SOCIAL_PLATFORM } from "../../src/shared/domain"; import { createId } from "../../src/shared/ids"; @@ -95,6 +95,71 @@ describe("social service", () => { connection.close(); }); + test("records browser login evidence metadata", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const launched: SocialLoginWindowInput[] = []; + const social = new SocialService(connection.db, (input) => { launched.push(input); }); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let employeeId = ""; + let socialAccountId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + employeeId = firstEmployeeId(connection, orgId); + socialAccountId = social.createAccount({ + orgId, + platform: SOCIAL_PLATFORM.LINKEDIN, + handle: "acme", + displayName: "Acme", + createdByEmployeeId: employeeId, + authorizedEmployeeId: employeeId, + }); + social.launchLoginWindow({ orgId, socialAccountId, employeeId }); + }); + + const state = snapshot.load(); + const action = state.browserActions[0]!; + const actionMetadata = JSON.parse(action.metadataJson) as { hostAllowlist: string[] }; + const audit = state.socialAuditRecords.find((record) => record.eventType === "login_window_opened"); + const auditMetadata = JSON.parse(audit!.metadataJson) as { hostAllowlist: string[] }; + + expect(launched).toHaveLength(1); + expect(launched[0]).toMatchObject({ + title: "acme", + loginUrl: "https://www.linkedin.com/login", + partitionKey: `persist:agentcompany-social-${socialAccountId}`, + sandboxEnabled: true, + }); + expect(launched[0]?.allowedHosts).toEqual(["linkedin.com", "www.linkedin.com"]); + expect(action).toMatchObject({ + socialAccountId, + actionType: "login_window_opened", + policyDecision: "allowed_human_login", + evidenceFileId: null, + }); + expect(actionMetadata).toMatchObject({ + platform: SOCIAL_PLATFORM.LINKEDIN, + loginUrl: "https://www.linkedin.com/login", + permissionPolicy: "deny_all", + sandboxEnabled: true, + actorType: "employee", + actorId: employeeId, + }); + expect(actionMetadata.hostAllowlist).toEqual(["linkedin.com", "www.linkedin.com"]); + expect(audit).toMatchObject({ + sourceType: "browser_action", + sourceId: action.id, + eventType: "login_window_opened", + actorType: "employee", + actorId: employeeId, + policyDecision: "allowed_human_login", + }); + expect(auditMetadata.hostAllowlist).toEqual(["linkedin.com", "www.linkedin.com"]); + connection.close(); + }); + test("pins files and schedules social posts behind approval", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);