From de63df7dafc0a1a2e049a7923ebc84dd8f45b3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 20 May 2026 23:40:40 -0400 Subject: [PATCH] Add policy governed task approvals --- AgentCompany-New/src/main/database/schema.ts | 15 ++++++ .../src/main/services/organization-service.ts | 9 +++- .../src/main/services/policy-service.ts | 48 +++++++++++++++++++ .../tests/unit/domain-services.test.ts | 1 + 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 AgentCompany-New/src/main/services/policy-service.ts diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index bebd504e..876a747b 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -357,6 +357,21 @@ export function installSchema(db: DatabaseSync): void { create index if not exists idx_provider_requests_org_created on provider_requests(org_id, created_at); create index if not exists idx_chat_threads_space_updated on chat_threads(space_id, updated_at); + insert into policy_rules(id, org_id, subject_type, subject_id, action, effect, risk_ceiling, created_at) + select + 'pol_' || lower(hex(randomblob(16))), + id, + 'organization', + id, + 'task_create', + 'require_approval', + 'medium', + datetime('now') + from organizations + where not exists ( + select 1 from policy_rules + where policy_rules.org_id = organizations.id and policy_rules.action = 'task_create' + ); insert into schema_migrations(version, name, applied_at) values (2, 'collaboration_provider_policy_schema', datetime('now')); `); diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index 21e80a78..6efd9397 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -4,12 +4,15 @@ import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { createId } from "../../shared/ids"; import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; +import { PolicyService } from "./policy-service"; export class OrganizationService { private readonly audit: AuditService; + private readonly policy: PolicyService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); + this.policy = new PolicyService(db); } createOrganization(input: CreateOrganizationInput): string { @@ -56,6 +59,7 @@ export class OrganizationService { ); } + this.policy.seedOrganizationPolicies(orgId, now); this.audit.record({ orgId, entityType: "organization", @@ -118,7 +122,8 @@ export class OrganizationService { now, now, ); - if (input.risk === RISK_LEVEL.HIGH) { + const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk); + if (policy.approvalRequired) { this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, @@ -146,7 +151,7 @@ export class OrganizationService { actorType: "system", actorId: null, action: "created", - payload: { status: TASK_STATUS.BACKLOG }, + payload: { status: TASK_STATUS.BACKLOG, policyRuleId: policy.matchedRuleId }, }); return id; } diff --git a/AgentCompany-New/src/main/services/policy-service.ts b/AgentCompany-New/src/main/services/policy-service.ts new file mode 100644 index 00000000..be98d877 --- /dev/null +++ b/AgentCompany-New/src/main/services/policy-service.ts @@ -0,0 +1,48 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { RiskLevel } from "../../shared/domain"; +import { RISK_LEVEL } from "../../shared/domain"; +import { createId } from "../../shared/ids"; + +const RISK_RANK: Record = { + [RISK_LEVEL.LOW]: 0, + [RISK_LEVEL.MEDIUM]: 1, + [RISK_LEVEL.HIGH]: 2, +}; + +export interface PolicyDecision { + approvalRequired: boolean; + matchedRuleId: string; +} + +export class PolicyService { + constructor(private readonly db: DatabaseSync) {} + + seedOrganizationPolicies(orgId: string, now: string): void { + this.db.prepare(` + insert into policy_rules(id, org_id, subject_type, subject_id, action, effect, risk_ceiling, created_at) + values (?, ?, ?, ?, ?, ?, ?, ?) + `).run(createId("pol"), orgId, "organization", orgId, "task_create", "require_approval", RISK_LEVEL.MEDIUM, now); + } + + evaluateTaskCreation(orgId: string, risk: RiskLevel): PolicyDecision { + const rule = this.db.prepare(` + select id, effect, risk_ceiling + from policy_rules + where org_id = ? and action = ? + order by created_at asc + limit 1 + `).get(orgId, "task_create") as PolicyRuleRow | undefined; + if (!rule) throw new Error("Policy rule is required for task creation."); + if (rule.effect !== "require_approval") throw new Error(`Unsupported policy effect: ${rule.effect}`); + return { + approvalRequired: RISK_RANK[risk] > RISK_RANK[rule.risk_ceiling], + matchedRuleId: rule.id, + }; + } +} + +interface PolicyRuleRow { + id: string; + effect: string; + risk_ceiling: RiskLevel; +} diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index b5e59509..9b8eec33 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -33,6 +33,7 @@ describe("domain services", () => { expect(state.employees[0]?.displayName).toBe("Dana Lee"); expect(state.providers.length).toBeGreaterThanOrEqual(6); expect(state.chatSpaces.map((space) => space.name)).toContain("board"); + expect(state.policyRules[0]?.action).toBe("task_create"); expect(state.auditEvents[0]?.action).toBe("created"); connection.close(); });