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
@@ -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