refactor(approvals): move execution policy into database
This commit is contained in:
1 parent
f8eb57ad8c
commit
9ee1425a71
12 files changed
+433
-101
No files matched your search
@@ -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}.`,
|
||||
|
||||
@@ -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<ApprovalPolicyDefinition, "approvalType"> = {
|
||||
scopeType: "requester_or_task",
|
||||
blocksExecution: false,
|
||||
requiredMode: "any",
|
||||
autoApprovable: false,
|
||||
};
|
||||
|
||||
const POLICY_OVERRIDES: Record<ApprovalType, Partial<Omit<ApprovalPolicyDefinition, "approvalType">>> = {
|
||||
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);
|
||||
@@ -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<ApprovalRecord>(
|
||||
`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;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ApprovalType>(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<ApprovalType>(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<ApprovalType>([
|
||||
"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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,14 @@ type IpcHandler = (
|
||||
...args: unknown[]
|
||||
) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>;
|
||||
|
||||
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<string, IpcHandler>();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user