From 9ee1425a7175dd3babef83e21c710d35f20e0fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 24 Apr 2026 00:33:56 -0400 Subject: [PATCH] refactor(approvals): move execution policy into database --- src/main/agent-runtime/hook-handlers.ts | 10 -- src/main/approval-policy-definitions.ts | 97 +++++++++++++++ src/main/database/approvals.ts | 89 +++++++++----- .../migrations/v58-approval-policies.ts | 110 ++++++++++++++++++ src/main/database/schema.ts | 20 +++- src/main/execution-mode-reconciler.ts | 24 ++-- src/main/execution-policy.ts | 57 +++------ tests/unit/cofounder-approval-gate.test.ts | 66 ++++++++++- tests/unit/company-mode-switch.test.ts | 22 ++++ tests/unit/database.test.ts | 33 ++++++ tests/unit/legacy-baseline-migration.test.ts | 3 + .../requires-filesystem-migration.test.ts | 3 +- 12 files changed, 433 insertions(+), 101 deletions(-) create mode 100644 src/main/approval-policy-definitions.ts create mode 100644 src/main/database/migrations/v58-approval-policies.ts diff --git a/src/main/agent-runtime/hook-handlers.ts b/src/main/agent-runtime/hook-handlers.ts index f02aae75..f2d09c45 100644 --- a/src/main/agent-runtime/hook-handlers.ts +++ b/src/main/agent-runtime/hook-handlers.ts @@ -27,10 +27,6 @@ import { appendArtifactProjectionEvent, appendArtifactProjectionFailureEvent, } from "./artifact-projection-events"; -import { - isCofounderMandatoryApproval, - isOperationalApproval, -} from "../execution-policy"; /** * Lifecycle phase of an active pi run as seen by main. @@ -120,12 +116,6 @@ function checkOperationalApprovalBlock( try { const blocking = deps.db.core.approvals.getBlockingApproval(ctx.companyId, ctx.agentId, ctx.taskId); if (!blocking) return { block: false, reason: null }; - const companyMode = deps.db.core.company.getCompanyExecutionMode(ctx.companyId); - const cofounderCheckpointBlocks = - companyMode === "ai_cofounder" && isCofounderMandatoryApproval(blocking.type); - if (!isOperationalApproval(blocking.type) && !cofounderCheckpointBlocks) { - return { block: false, reason: null }; - } return { block: true, reason: `Approval ${blocking.id} (${blocking.type}, state=${blocking.state}) is outstanding: ${blocking.payloadSummary}. Wait for the decision before retrying ${toolName}.`, diff --git a/src/main/approval-policy-definitions.ts b/src/main/approval-policy-definitions.ts new file mode 100644 index 00000000..d264c89b --- /dev/null +++ b/src/main/approval-policy-definitions.ts @@ -0,0 +1,97 @@ +import { approvalTypeEnum, type ApprovalType, type ExecutionMode } from "@shared/types"; + +export type ApprovalPolicyScope = "requester_or_task" | "related_agent"; +export type ApprovalPolicyRequiredMode = "any" | ExecutionMode; + +export interface ApprovalPolicyDefinition { + approvalType: ApprovalType; + scopeType: ApprovalPolicyScope; + blocksExecution: boolean; + requiredMode: ApprovalPolicyRequiredMode; + autoApprovable: boolean; +} + +const DEFAULT_POLICY: Omit = { + scopeType: "requester_or_task", + blocksExecution: false, + requiredMode: "any", + autoApprovable: false, +}; + +const POLICY_OVERRIDES: Record>> = { + hire_agent: { + scopeType: "related_agent", + blocksExecution: true, + autoApprovable: true, + }, + approve_ceo_strategy: { + blocksExecution: true, + requiredMode: "ai_cofounder", + autoApprovable: true, + }, + dangerous_command: { + blocksExecution: true, + }, + secret_access: { + blocksExecution: true, + }, + budget_request: {}, + promotion: {}, + termination: {}, + vendor_approval: {}, + document_review: { + blocksExecution: true, + requiredMode: "ai_cofounder", + }, + production_deploy: { + blocksExecution: true, + }, + incident_escalation: {}, + social_post: { + blocksExecution: true, + }, + deliverable_review: { + blocksExecution: true, + requiredMode: "ai_cofounder", + }, + budget_override: { + blocksExecution: true, + }, + plan_review: { + blocksExecution: true, + requiredMode: "ai_cofounder", + }, + scope_review: { + blocksExecution: true, + requiredMode: "ai_cofounder", + }, + draft_review: { + blocksExecution: true, + requiredMode: "ai_cofounder", + }, + strategy_review: { + blocksExecution: true, + requiredMode: "ai_cofounder", + }, + exception_review: { + blocksExecution: true, + requiredMode: "ai_cofounder", + }, +}; + +export const APPROVAL_POLICY_SEED: readonly ApprovalPolicyDefinition[] = approvalTypeEnum.options.map((approvalType) => ({ + approvalType, + ...DEFAULT_POLICY, + ...POLICY_OVERRIDES[approvalType], +})); + +export const COFOUNDER_MANDATORY_APPROVAL_TYPES: readonly ApprovalType[] = APPROVAL_POLICY_SEED + .filter((policy) => policy.blocksExecution && policy.requiredMode === "ai_cofounder") + .map((policy) => policy.approvalType); + +export const OPERATIONAL_APPROVAL_TYPES: readonly ApprovalType[] = APPROVAL_POLICY_SEED + .filter((policy) => + policy.blocksExecution && + policy.requiredMode === "any" && + policy.scopeType === "requester_or_task") + .map((policy) => policy.approvalType); diff --git a/src/main/database/approvals.ts b/src/main/database/approvals.ts index 4c5db659..947e9341 100644 --- a/src/main/database/approvals.ts +++ b/src/main/database/approvals.ts @@ -3,11 +3,15 @@ import type { DatabaseSync, SQLInputValue } from "node:sqlite"; import log from "electron-log/main.js"; import type { ApprovalRecord } from "@shared/types"; import type { ReadAll, Transaction } from "./helpers"; -import { normalizeOptionalId, nowIso, parseJson } from "./helpers"; -import { - COFOUNDER_MANDATORY_APPROVAL_TYPES, - OPERATIONAL_APPROVAL_TYPES, -} from "../execution-policy"; +import { normalizeOptionalId, nowIso, parseJson, toBoolean } from "./helpers"; + +export interface ApprovalPolicyRecord { + approvalType: string; + scopeType: string; + blocksExecution: boolean; + requiredMode: "any" | "ai_ceo" | "ai_cofounder"; + autoApprovable: boolean; +} export function buildApprovalMethods( getDb: () => DatabaseSync, @@ -239,36 +243,65 @@ export function buildApprovalMethods( return readAll(sql, ...args); }, + getApprovalPolicy(approvalType: string): ApprovalPolicyRecord | null { + const row = getDb() + .prepare( + "select approval_type as approvalType, scope_type as scopeType, blocks_execution as blocksExecution, required_mode as requiredMode, auto_approvable as autoApprovable from approval_policies where approval_type = ?", + ) + .get(approvalType) as { + approvalType: string; + scopeType: string; + blocksExecution: number; + requiredMode: "any" | "ai_ceo" | "ai_cofounder"; + autoApprovable: number; + } | undefined; + return row + ? { + approvalType: row.approvalType, + scopeType: row.scopeType, + blocksExecution: toBoolean(row.blocksExecution), + requiredMode: row.requiredMode, + autoApprovable: toBoolean(row.autoApprovable), + } + : null; + }, + getBlockingApproval(companyId: string, agentId: string, taskId: string | null): ApprovalRecord | null { - const taskCondition = taskId != null - ? "(related_task_id = ? or requested_by_agent_id = ?)" - : "requested_by_agent_id = ?"; - const taskParams: SQLInputValue[] = taskId != null ? [taskId, agentId] : [agentId]; - const operationalApprovalPlaceholders = OPERATIONAL_APPROVAL_TYPES.map(() => "?").join(", "); - const cofounderApprovalPlaceholders = COFOUNDER_MANDATORY_APPROVAL_TYPES.map(() => "?").join(", "); + const company = getDb() + .prepare("select execution_mode as executionMode from companies where id = ?") + .get(companyId) as { executionMode: "ai_ceo" | "ai_cofounder" } | undefined; + if (!company) { + throw new Error(`Company not found for approval gate: ${companyId}`); + } const rows = readAll( `select id, company_id as companyId, related_task_id as relatedTaskId, requested_by_agent_id as requestedByAgentId, related_agent_id as relatedAgentId, - type, payload_summary as payloadSummary, impact_summary as impactSummary, + a.type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, - created_at as createdAt, updated_at as updatedAt - from approvals - where company_id = ? and state = 'pending' + a.created_at as createdAt, a.updated_at as updatedAt + from approvals a + inner join approval_policies p on p.approval_type = a.type + where a.company_id = ? + and a.state = 'pending' + and p.blocks_execution = 1 + and (p.required_mode = 'any' or p.required_mode = ?) and ( - (type = 'hire_agent' and related_agent_id = ?) - or (type = 'dangerous_command' and ${taskCondition}) - or (type = 'secret_access' and ${taskCondition}) - or (type = 'approve_ceo_strategy' and ${taskCondition}) - or (type = 'deliverable_review' and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition}))) - or (type in (${operationalApprovalPlaceholders}) and ${taskCondition}) - or (type in (${cofounderApprovalPlaceholders}) and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition}))) + (p.scope_type = 'related_agent' and a.related_agent_id = ?) + or ( + p.scope_type = 'requester_or_task' + and ( + a.requested_by_agent_id = ? + or (? is not null and a.related_task_id = ?) + ) + ) ) - order by created_at asc limit 1`, - companyId, agentId, - ...taskParams, ...taskParams, ...taskParams, - agentId, ...taskParams, - ...OPERATIONAL_APPROVAL_TYPES, ...taskParams, - ...COFOUNDER_MANDATORY_APPROVAL_TYPES, agentId, ...taskParams, + order by a.created_at asc limit 1`, + companyId, + company.executionMode, + agentId, + agentId, + taskId, + taskId, ); return rows.length > 0 ? rows[0] : null; }, diff --git a/src/main/database/migrations/v58-approval-policies.ts b/src/main/database/migrations/v58-approval-policies.ts new file mode 100644 index 00000000..4d008481 --- /dev/null +++ b/src/main/database/migrations/v58-approval-policies.ts @@ -0,0 +1,110 @@ +import type { DatabaseSync } from "node:sqlite"; +import { APPROVAL_POLICY_SEED } from "../../approval-policy-definitions"; +import { nowIso, tableExists } from "./types"; + +export const APPROVAL_POLICY_SCHEMA_VERSION = 58; +export const APPROVAL_POLICY_SCHEMA_NAME = "approval_policy_table"; + +export function migrateApprovalPoliciesV58(db: DatabaseSync): void { + installApprovalPoliciesTable(db); + seedApprovalPolicies(db); + db + .prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)") + .run(APPROVAL_POLICY_SCHEMA_VERSION, APPROVAL_POLICY_SCHEMA_NAME, nowIso()); + assertApprovalPoliciesV58(db); +} + +export function assertApprovalPoliciesV58(db: DatabaseSync): void { + if (!tableExists(db, "approval_policies")) { + throw new Error("Schema v58 drift: approval_policies table is missing."); + } + + const columns = db + .prepare("pragma table_info(approval_policies)") + .all() + .map((row) => row as { name: string; type: string; notnull: number; pk: number }); + const actualColumns = columns.map((column) => [ + column.name, + column.type.toUpperCase(), + column.notnull, + column.pk, + ]); + const expectedColumns = [ + ["approval_type", "TEXT", 0, 1], + ["scope_type", "TEXT", 1, 0], + ["blocks_execution", "INTEGER", 1, 0], + ["required_mode", "TEXT", 1, 0], + ["auto_approvable", "INTEGER", 1, 0], + ["created_at", "TEXT", 1, 0], + ["updated_at", "TEXT", 1, 0], + ]; + if (JSON.stringify(actualColumns) !== JSON.stringify(expectedColumns)) { + throw new Error("Schema v58 drift: approval_policies columns do not match the canonical schema."); + } + + const rows = db + .prepare( + "select approval_type as approvalType, scope_type as scopeType, blocks_execution as blocksExecution, required_mode as requiredMode, auto_approvable as autoApprovable from approval_policies order by approval_type", + ) + .all() as Array<{ + approvalType: string; + scopeType: string; + blocksExecution: number; + requiredMode: string; + autoApprovable: number; + }>; + const actual = rows.map((row) => ({ + approvalType: row.approvalType, + scopeType: row.scopeType, + blocksExecution: row.blocksExecution, + requiredMode: row.requiredMode, + autoApprovable: row.autoApprovable, + })); + const expected = [...APPROVAL_POLICY_SEED] + .sort((a, b) => a.approvalType.localeCompare(b.approvalType)) + .map((policy) => ({ + approvalType: policy.approvalType, + scopeType: policy.scopeType, + blocksExecution: policy.blocksExecution ? 1 : 0, + requiredMode: policy.requiredMode, + autoApprovable: policy.autoApprovable ? 1 : 0, + })); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error("Schema v58 drift: approval_policies seed rows do not match the canonical policy."); + } +} + +function installApprovalPoliciesTable(db: DatabaseSync): void { + db.exec(` + create table if not exists approval_policies ( + approval_type text primary key, + scope_type text not null check (scope_type in ('requester_or_task', 'related_agent')), + blocks_execution integer not null check (blocks_execution in (0, 1)), + required_mode text not null check (required_mode in ('any', 'ai_ceo', 'ai_cofounder')), + auto_approvable integer not null check (auto_approvable in (0, 1)), + created_at text not null, + updated_at text not null + ); + `); +} + +function seedApprovalPolicies(db: DatabaseSync): void { + const ts = nowIso(); + const stmt = db.prepare( + `insert or replace into approval_policies ( + approval_type, scope_type, blocks_execution, required_mode, auto_approvable, created_at, updated_at + ) values (?, ?, ?, ?, ?, coalesce((select created_at from approval_policies where approval_type = ?), ?), ?)`, + ); + for (const policy of APPROVAL_POLICY_SEED) { + stmt.run( + policy.approvalType, + policy.scopeType, + policy.blocksExecution ? 1 : 0, + policy.requiredMode, + policy.autoApprovable ? 1 : 0, + policy.approvalType, + ts, + ts, + ); + } +} diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 77213086..b9a398e6 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -14,12 +14,17 @@ import { assertTaskLeasesV57, migrateTaskLeasesV57, } from "./migrations/v57-task-leases"; +import { + APPROVAL_POLICY_SCHEMA_VERSION, + assertApprovalPoliciesV58, + migrateApprovalPoliciesV58, +} from "./migrations/v58-approval-policies"; import { type ReadAll, type Transaction, } from "./migrations/types"; -const LATEST_SCHEMA_VERSION = TASK_LEASE_SCHEMA_VERSION; +const LATEST_SCHEMA_VERSION = APPROVAL_POLICY_SCHEMA_VERSION; export function initializeDatabase( db: DatabaseSync, @@ -38,6 +43,16 @@ export function initializeDatabase( tx(() => { seedSettings(db); assertTaskLeasesV57(db); + assertApprovalPoliciesV58(db); + }); + return; + } + + if (currentVersion === TASK_LEASE_SCHEMA_VERSION) { + tx(() => { + seedSettings(db); + assertTaskLeasesV57(db); + migrateApprovalPoliciesV58(db); }); return; } @@ -49,6 +64,7 @@ export function initializeDatabase( seedSettings(db); assertBaselineSchema(db); migrateTaskLeasesV57(db); + migrateApprovalPoliciesV58(db); }); return; } @@ -61,6 +77,7 @@ export function initializeDatabase( seedSettings(db); assertBaselineSchema(db); migrateTaskLeasesV57(db); + migrateApprovalPoliciesV58(db); }); return; } @@ -71,6 +88,7 @@ export function initializeDatabase( seedSettings(db); assertBaselineSchema(db); migrateTaskLeasesV57(db); + migrateApprovalPoliciesV58(db); }); } diff --git a/src/main/execution-mode-reconciler.ts b/src/main/execution-mode-reconciler.ts index 31fdc0c9..35c1a944 100644 --- a/src/main/execution-mode-reconciler.ts +++ b/src/main/execution-mode-reconciler.ts @@ -12,17 +12,6 @@ type ReconcileExecutionModeChangeInput = { handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; }; -const COFOUNDER_TRANSITION_APPROVAL_TYPES = [ - "deliverable_review", - "document_review", - "plan_review", - "scope_review", - "draft_review", - "strategy_review", - "exception_review", - "approve_ceo_strategy", -] as const; - const REVIEW_WAIT_STATE_TYPES = new Set(["user_reply", "user_review", "user_approval"]); function collectImpactedAgentIdsFromWaitStates(db: AppDatabase, companyId: string): Set { @@ -122,13 +111,14 @@ export function reconcileExecutionModeChange({ if (leavingCofounderMode) { const pendingApprovals = db.queryAll<{ id: string; type: string }>( - `select id, type - from approvals - where company_id = ? - and state = 'pending' - and type in (${COFOUNDER_TRANSITION_APPROVAL_TYPES.map(() => "?").join(", ")})`, + `select a.id, a.type + from approvals a + inner join approval_policies p on p.approval_type = a.type + where a.company_id = ? + and a.state = 'pending' + and p.blocks_execution = 1 + and p.required_mode = 'ai_cofounder'`, companyId, - ...COFOUNDER_TRANSITION_APPROVAL_TYPES, ); for (const approval of pendingApprovals) { resolveApprovalDecision({ diff --git a/src/main/execution-policy.ts b/src/main/execution-policy.ts index 0d0bbcd2..6594bdd4 100644 --- a/src/main/execution-policy.ts +++ b/src/main/execution-policy.ts @@ -1,17 +1,22 @@ /** - * Execution Policy — Single Source of Truth + * Execution Policy * - * Centralizes all execution-mode-aware policy decisions: - * - Which approval types require human review in co-founder mode - * - Which approval types can be auto-approved in CEO mode - * - Whether a given approval should be auto-approved based on mode + company settings - * - * checkpoint-engine.ts and approval-policy.ts both delegate here - * instead of maintaining separate rule sets. + * Runtime decisions read the seeded approval_policies table. The exported + * constants below exist for prompt/docs contracts that need the same seed + * vocabulary without opening SQLite. */ import type { AppDatabase } from "./database"; import type { ApprovalType, ExecutionMode } from "@shared/types"; +export { + APPROVAL_POLICY_SEED, + COFOUNDER_MANDATORY_APPROVAL_TYPES, + OPERATIONAL_APPROVAL_TYPES, +} from "./approval-policy-definitions"; +import { + COFOUNDER_MANDATORY_APPROVAL_TYPES, + OPERATIONAL_APPROVAL_TYPES, +} from "./approval-policy-definitions"; function assertExecutionMode(value: string): asserts value is ExecutionMode { if (value !== "ai_ceo" && value !== "ai_cofounder") { @@ -19,46 +24,18 @@ function assertExecutionMode(value: string): asserts value is ExecutionMode { } } -/** Approval types that always require human review in co-founder mode. */ -export const COFOUNDER_MANDATORY_APPROVAL_TYPES = [ - "deliverable_review", - "document_review", - "plan_review", - "scope_review", - "draft_review", - "strategy_review", - "exception_review", - "approve_ceo_strategy", - "production_deploy", -] as const satisfies readonly ApprovalType[]; - const COFOUNDER_MANDATORY_APPROVALS = new Set(COFOUNDER_MANDATORY_APPROVAL_TYPES); export function isCofounderMandatoryApproval(approvalType: string): approvalType is ApprovalType { return COFOUNDER_MANDATORY_APPROVALS.has(approvalType as ApprovalType); } -/** Approval types that always gate destructive tools while pending. */ -export const OPERATIONAL_APPROVAL_TYPES = [ - "dangerous_command", - "secret_access", - "production_deploy", - "social_post", - "budget_override", -] as const satisfies readonly ApprovalType[]; - const OPERATIONAL_APPROVALS = new Set(OPERATIONAL_APPROVAL_TYPES); export function isOperationalApproval(approvalType: string): approvalType is ApprovalType { return OPERATIONAL_APPROVALS.has(approvalType as ApprovalType); } -/** Approval types eligible for auto-approval in CEO mode (when company settings allow). */ -const CEO_AUTO_APPROVABLE = new Set([ - "hire_agent", - "approve_ceo_strategy", -]); - /** Task types that get plan_review checkpoints in co-founder mode. */ export const CHECKPOINT_TASK_TYPES = new Set(["research", "content", "management"]); @@ -78,14 +55,12 @@ export function shouldAutoApprove( const rawMode = db.core.company.getCompanyExecutionMode(companyId); assertExecutionMode(rawMode); const mode = rawMode; - - // Co-founder mode: mandatory types always require human decision - if (mode === "ai_cofounder" && isCofounderMandatoryApproval(approvalType)) { + const policy = db.core.approvals.getApprovalPolicy(approvalType); + if (!policy?.autoApprovable) { return false; } - // Only specific types are ever auto-approvable - if (!CEO_AUTO_APPROVABLE.has(approvalType as ApprovalType)) { + if (mode === "ai_cofounder" && policy.requiredMode === "ai_cofounder") { return false; } diff --git a/tests/unit/cofounder-approval-gate.test.ts b/tests/unit/cofounder-approval-gate.test.ts index 2e0dc451..d1f9589a 100644 --- a/tests/unit/cofounder-approval-gate.test.ts +++ b/tests/unit/cofounder-approval-gate.test.ts @@ -18,12 +18,12 @@ import { type ActiveRunContext, } from "../../src/main/agent-runtime/hook-handlers"; -function seedCofounderRun(db: AppDatabase) { +function seedRun(db: AppDatabase, executionMode: "ai_ceo" | "ai_cofounder" = "ai_cofounder") { const companyId = db.core.company.saveCompany({ name: "Gate Co", description: "Cofounder runtime gate test", status: "active", - executionMode: "ai_cofounder", + executionMode, }); db.core.connectors.saveConnector({ id: "openai-codex", @@ -87,7 +87,7 @@ describe("cofounder approval runtime gate", () => { }); it("blocks destructive tools while a cofounder plan review is pending", () => { - const { companyId, agentId, taskId, runContext } = seedCofounderRun(db); + const { companyId, agentId, taskId, runContext } = seedRun(db, "ai_cofounder"); const approvalId = db.core.approvals.requestApproval({ companyId, relatedTaskId: taskId, @@ -117,4 +117,64 @@ describe("cofounder approval runtime gate", () => { expect(result.block).toBe(true); expect(result.reason).toContain("plan_review"); }); + + it("lets CEO-mode execution proceed past plan review policy", () => { + const { companyId, agentId, taskId, runContext } = seedRun(db, "ai_ceo"); + db.core.approvals.requestApproval({ + companyId, + relatedTaskId: taskId, + requestedByAgentId: agentId, + type: "plan_review", + payloadSummary: "Review plan", + impactSummary: "Human review requested for planning.", + payloadJson: "{}", + }); + + expect(db.core.approvals.getBlockingApproval(companyId, agentId, taskId)).toBeNull(); + const result = handleBeforeToolCall( + { + db, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + }, + runContext, + { + toolName: "bash", + toolCallId: "call-1", + args: { command: "touch allowed.txt" }, + }, + ); + + expect(result.block).toBe(false); + }); + + it("blocks operational approvals in CEO mode", () => { + const { companyId, agentId, taskId, runContext } = seedRun(db, "ai_ceo"); + const approvalId = db.core.approvals.requestApproval({ + companyId, + relatedTaskId: taskId, + requestedByAgentId: agentId, + type: "dangerous_command", + payloadSummary: "Approve destructive command", + impactSummary: "Host-level mutation requires explicit approval.", + payloadJson: "{}", + }); + + const blocking = db.core.approvals.getBlockingApproval(companyId, agentId, taskId); + expect(blocking?.id).toBe(approvalId); + const result = handleBeforeToolCall( + { + db, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + }, + runContext, + { + toolName: "bash", + toolCallId: "call-1", + args: { command: "rm -rf dist" }, + }, + ); + + expect(result.block).toBe(true); + expect(result.reason).toContain("dangerous_command"); + }); }); diff --git a/tests/unit/company-mode-switch.test.ts b/tests/unit/company-mode-switch.test.ts index 8f8a5c97..cee99421 100644 --- a/tests/unit/company-mode-switch.test.ts +++ b/tests/unit/company-mode-switch.test.ts @@ -14,6 +14,14 @@ type IpcHandler = ( ...args: unknown[] ) => Promise> | DesktopResult; +type RawDb = { + db: { + prepare: (sql: string) => { + run: (...args: unknown[]) => void; + }; + }; +}; + const tempDirs: string[] = []; async function createTempDir(prefix: string) { @@ -111,10 +119,23 @@ describe("company mode switching", () => { payloadJson: "{}", }); threadIntegration.onApprovalCreated(approvalId, companyId, taskId, "deliverable_review", "Review deliverable"); + (db as unknown as RawDb).db + .prepare("update approval_policies set blocks_execution = 1, required_mode = 'ai_cofounder', updated_at = ? where approval_type = 'incident_escalation'") + .run(new Date().toISOString()); + const policyDrivenApprovalId = db.core.approvals.requestApproval({ + companyId, + relatedTaskId: taskId, + requestedByAgentId: engineerId, + type: "incident_escalation", + payloadSummary: "Review incident policy", + impactSummary: "Data-driven cofounder review policy.", + payloadJson: "{}", + }); expect(db.core.threads.listThreadWaitStates(companyId).length).toBeGreaterThan(0); expect(db.core.entity.getTask(taskId).requiresUserReview).toBe(true); expect(db.core.approvals.getApprovalRecord(approvalId).state).toBe("pending"); + expect(db.core.approvals.getApprovalRecord(policyDrivenApprovalId).state).toBe("pending"); const handlers = new Map(); registerCoreAdminHandlers({ @@ -151,6 +172,7 @@ describe("company mode switching", () => { expect(db.core.threads.listThreadWaitStates(companyId)).toHaveLength(0); expect(db.core.entity.getTask(taskId).requiresUserReview).toBe(false); expect(db.core.approvals.getApprovalRecord(approvalId).state).toBe("approved"); + expect(db.core.approvals.getApprovalRecord(policyDrivenApprovalId).state).toBe("approved"); db.close(); }); diff --git a/tests/unit/database.test.ts b/tests/unit/database.test.ts index c84c82ad..e97e5b8a 100644 --- a/tests/unit/database.test.ts +++ b/tests/unit/database.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { mkdtemp, rm } from "node:fs/promises"; import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { approvalTypeEnum } from "../../src/shared/types"; beforeAll(() => { process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true"; @@ -479,6 +480,7 @@ describe("AppDatabase", () => { expect(migrations).toEqual([ { version: 56, name: "baseline_v56" }, { version: 57, name: "task_leases_runtime_ownership" }, + { version: 58, name: "approval_policy_table" }, ]); db.close(); @@ -491,6 +493,7 @@ describe("AppDatabase", () => { expect(migrationsAfterReinit).toEqual([ { version: 56, name: "baseline_v56" }, { version: 57, name: "task_leases_runtime_ownership" }, + { version: 58, name: "approval_policy_table" }, ]); }); @@ -535,6 +538,36 @@ describe("AppDatabase", () => { } }); + it("seeds approval policies for every approval type", () => { + const rows = db.queryAll<{ + approvalType: string; + scopeType: string; + blocksExecution: number; + requiredMode: string; + autoApprovable: number; + }>( + "select approval_type as approvalType, scope_type as scopeType, blocks_execution as blocksExecution, required_mode as requiredMode, auto_approvable as autoApprovable from approval_policies order by approval_type", + ); + expect(rows.map((row) => row.approvalType).sort()).toEqual([...approvalTypeEnum.options].sort()); + expect(rows.find((row) => row.approvalType === "plan_review")).toMatchObject({ + scopeType: "requester_or_task", + blocksExecution: 1, + requiredMode: "ai_cofounder", + autoApprovable: 0, + }); + expect(rows.find((row) => row.approvalType === "dangerous_command")).toMatchObject({ + scopeType: "requester_or_task", + blocksExecution: 1, + requiredMode: "any", + }); + expect(rows.find((row) => row.approvalType === "hire_agent")).toMatchObject({ + scopeType: "related_agent", + blocksExecution: 1, + requiredMode: "any", + autoApprovable: 1, + }); + }); + it("acquires and releases workspace locks", () => { const companyId = db.core.company.saveCompany({ name: "Lock Test Co", diff --git a/tests/unit/legacy-baseline-migration.test.ts b/tests/unit/legacy-baseline-migration.test.ts index 98686e2b..02ffdf35 100644 --- a/tests/unit/legacy-baseline-migration.test.ts +++ b/tests/unit/legacy-baseline-migration.test.ts @@ -172,6 +172,7 @@ describe("legacy to v56 baseline migration", () => { expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([ { version: 56, name: "baseline_v56" }, { version: 57, name: "task_leases_runtime_ownership" }, + { version: 58, name: "approval_policy_table" }, ]); expect(appDb.queryAll<{ companyId: string }>("select company_id as companyId from secrets where id = 'secret-1'")[0]) .toEqual({ companyId: "company-1" }); @@ -204,6 +205,7 @@ describe("legacy to v56 baseline migration", () => { expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([ { version: 56, name: "baseline_v56" }, { version: 57, name: "task_leases_runtime_ownership" }, + { version: 58, name: "approval_policy_table" }, ]); expect(appDb.queryAll("select id from budget_policies where scope_type = 'agent' and scope_id = 'agent-1'")) .toHaveLength(1); @@ -363,6 +365,7 @@ describe("legacy to v56 baseline migration", () => { expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([ { version: 56, name: "baseline_v56" }, { version: 57, name: "task_leases_runtime_ownership" }, + { version: 58, name: "approval_policy_table" }, ]); } finally { appDb.close(); diff --git a/tests/unit/requires-filesystem-migration.test.ts b/tests/unit/requires-filesystem-migration.test.ts index 338a949c..67af8f54 100644 --- a/tests/unit/requires-filesystem-migration.test.ts +++ b/tests/unit/requires-filesystem-migration.test.ts @@ -116,11 +116,12 @@ describe("legacy to v56 baseline migration", () => { { id: "task-inflight", requiresFilesystem: 1 }, ]); const migrations = appDb.queryAll<{ version: number; name: string }>( - "select version, name from schema_migrations where version in (56, 57) order by version", + "select version, name from schema_migrations where version in (56, 57, 58) order by version", ); expect(migrations).toEqual([ { version: 56, name: "baseline_v56" }, { version: 57, name: "task_leases_runtime_ownership" }, + { version: 58, name: "approval_policy_table" }, ]); } finally { appDb.close();