fix(runtime): gate cofounder checkpoints
This commit is contained in:
1 parent
7a1dd647aa
commit
43583ef30b
5 files changed
+162
-25
No files matched your search
@@ -14,7 +14,6 @@
|
||||
* Policy lives here; `RuntimeBridge` stays a pure IPC router.
|
||||
*/
|
||||
|
||||
import type { ApprovalType } from "@shared/types";
|
||||
import type { AppDatabase } from "@main/database";
|
||||
import type {
|
||||
AfterToolCallRpcPayload,
|
||||
@@ -29,6 +28,10 @@ import {
|
||||
type ArtifactProjectorRunContext,
|
||||
} from "./artifact-projector";
|
||||
import { compactMessagesIfNeeded } from "./context-compactor";
|
||||
import {
|
||||
isCofounderMandatoryApproval,
|
||||
isOperationalApproval,
|
||||
} from "../execution-policy";
|
||||
|
||||
/**
|
||||
* Lifecycle phase of an active pi run as seen by main.
|
||||
@@ -87,25 +90,6 @@ export function isDestructiveTool(toolName: string): boolean {
|
||||
return DESTRUCTIVE_TOOLS.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval types that gate EXECUTION (not decision). These stop the
|
||||
* destructive toolchain until the board releases the hold.
|
||||
*
|
||||
* Decision-type approvals (deliverable_review, plan_review, hire_agent,
|
||||
* …) are deliberately excluded: they mean "a human owes an answer
|
||||
* about completed or proposed work". The agent is still free to reply
|
||||
* to messages, coordinate with peers, or work on unrelated tasks
|
||||
* while the decision is pending — blocking their tools there turns
|
||||
* the board-review pattern into a company-wide freeze.
|
||||
*/
|
||||
const OPERATIONAL_APPROVAL_TYPES: ReadonlySet<ApprovalType> = new Set<ApprovalType>([
|
||||
"dangerous_command",
|
||||
"secret_access",
|
||||
"production_deploy",
|
||||
"social_post",
|
||||
"budget_override",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Approval gate for destructive tools. Runs at every
|
||||
* `beforeToolCall`, so approvals created mid-run take effect on the
|
||||
@@ -136,7 +120,10 @@ function checkOperationalApprovalBlock(
|
||||
try {
|
||||
const blocking = deps.db.approvalsMod.getBlockingApproval(ctx.companyId, ctx.agentId, ctx.taskId);
|
||||
if (!blocking) return { block: false, reason: null };
|
||||
if (!OPERATIONAL_APPROVAL_TYPES.has(blocking.type as ApprovalType)) {
|
||||
const companyMode = deps.db.company.getCompanyExecutionMode(ctx.companyId);
|
||||
const cofounderCheckpointBlocks =
|
||||
companyMode === "ai_cofounder" && isCofounderMandatoryApproval(blocking.type);
|
||||
if (!isOperationalApproval(blocking.type) && !cofounderCheckpointBlocks) {
|
||||
return { block: false, reason: null };
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,10 @@ 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";
|
||||
|
||||
export function buildApprovalMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
@@ -240,6 +244,8 @@ export function buildApprovalMethods(
|
||||
? "(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 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,
|
||||
@@ -254,11 +260,15 @@ export function buildApprovalMethods(
|
||||
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})))
|
||||
)
|
||||
order by created_at asc limit 1`,
|
||||
companyId, agentId,
|
||||
...taskParams, ...taskParams, ...taskParams,
|
||||
agentId, ...taskParams,
|
||||
...OPERATIONAL_APPROVAL_TYPES, ...taskParams,
|
||||
...COFOUNDER_MANDATORY_APPROVAL_TYPES, agentId, ...taskParams,
|
||||
);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ function assertExecutionMode(value: string): asserts value is ExecutionMode {
|
||||
}
|
||||
|
||||
/** Approval types that always require human review in co-founder mode. */
|
||||
const COFOUNDER_MANDATORY_APPROVALS = new Set<ApprovalType>([
|
||||
export const COFOUNDER_MANDATORY_APPROVAL_TYPES = [
|
||||
"deliverable_review",
|
||||
"document_review",
|
||||
"plan_review",
|
||||
@@ -30,7 +30,28 @@ const COFOUNDER_MANDATORY_APPROVALS = new Set<ApprovalType>([
|
||||
"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>([
|
||||
@@ -59,7 +80,7 @@ export function shouldAutoApprove(
|
||||
const mode = rawMode;
|
||||
|
||||
// Co-founder mode: mandatory types always require human decision
|
||||
if (mode === "ai_cofounder" && COFOUNDER_MANDATORY_APPROVALS.has(approvalType as ApprovalType)) {
|
||||
if (mode === "ai_cofounder" && isCofounderMandatoryApproval(approvalType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ export function importantRulesSection(companyId: string, agentId: string, runId:
|
||||
# Critical Rules
|
||||
1. Only work on the tasks and goals described above. Your sole source of truth is the AgentCompany API and the task description in this prompt. Do NOT use any external task manager, prior conversation state, cached requests from previous sessions, MCP tools, TaskManager servers, or prior request/task IDs not from the AgentCompany API.
|
||||
2. Stay in the working directory you were started in. Do NOT cd or navigate to other projects or repositories.
|
||||
3. Actually call the API to take actions. Do NOT just describe what you would do — DO it. Every wake cycle should result in at least one API call that moves work forward.
|
||||
3. Actually call the API to take actions. Do NOT just describe what you would do — DO it. Every wake cycle should result in at least one API call that moves work forward unless a runtime checkpoint or approval gate blocks the current work.
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import {
|
||||
handleBeforeToolCall,
|
||||
type ActiveRunContext,
|
||||
} from "../../src/main/agent-runtime/hook-handlers";
|
||||
|
||||
function seedCofounderRun(db: AppDatabase) {
|
||||
const companyId = db.company.saveCompany({
|
||||
name: "Gate Co",
|
||||
description: "Cofounder runtime gate test",
|
||||
status: "active",
|
||||
executionMode: "ai_cofounder",
|
||||
});
|
||||
db.connectorsCrud.saveConnector({
|
||||
id: "openai-codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "fixture",
|
||||
});
|
||||
const agentId = db.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Researcher",
|
||||
role: "Research Lead",
|
||||
title: "Research Lead",
|
||||
department: "product",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Research",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const taskId = db.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Draft market plan",
|
||||
description: "Prepare the market plan after approval.",
|
||||
assigneeAgentId: agentId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
const runContext: ActiveRunContext = {
|
||||
runId: "run-1",
|
||||
agentId,
|
||||
companyId,
|
||||
taskId,
|
||||
connectorId: "openai-codex",
|
||||
modelId: "gpt-5",
|
||||
taskSessionId: "session-1",
|
||||
workspaceId: null,
|
||||
workspacePath: null,
|
||||
workspaceBaseline: null,
|
||||
state: "agent_active",
|
||||
};
|
||||
return { companyId, agentId, taskId, runContext };
|
||||
}
|
||||
|
||||
describe("cofounder approval runtime gate", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cofounder-gate-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("blocks destructive tools while a cofounder plan review is pending", () => {
|
||||
const { companyId, agentId, taskId, runContext } = seedCofounderRun(db);
|
||||
const approvalId = db.approvalsMod.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: agentId,
|
||||
type: "plan_review",
|
||||
payloadSummary: "Review plan",
|
||||
impactSummary: "Human approval is required before execution.",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
|
||||
const blocking = db.approvalsMod.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: "touch blocked.txt" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.block).toBe(true);
|
||||
expect(result.reason).toContain("plan_review");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user