fix: require board authority for agent budget policies

Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit f970f6b01f)
This commit is contained in:
Dotta committed 2026-11-07 14:12:15 -06:00
1 parent 40f9d71214
commit 0113b10ce5
6 files changed
+95 -14

No files matched your search

+3 -1
View File
@@ -83,7 +83,7 @@ An unfinished record keeps compatibility status `paused` with a
`setup` hold and cannot receive API keys or authenticate with keys or JWTs.
Its standard grants apply at hire. Authorized completion retries recover the
same record and approval without changing the submitted configuration.
Normal agent-authored hiring keeps its existing approval policy.
Normal agent-authored hiring keeps its existing approval policy. A pending hire may propose a budget, but only board approval activates that policy. Direct creation or an immediate hire with a positive budget requires a board actor.
An agent-owned plugin may prepare the draft and leave its lifecycle in
`verifying` while that setup hold remains. Only bounded Core-issued setup probes
@@ -212,6 +212,8 @@ CLI.
Policy: Agent creation/configuration uses action grants, resource policies and change
consent; some controls require a board actor.
The legacy `budgetMonthlyCents` field requires a board actor for updates or budget-changing rollbacks. Agent-authored rollbacks with an unchanged amount do not write budget policy or policy audit. Board mutations retain the actual operator identity.
The roster intent preserves unrelated settings, rejects stale model/connection
previews and harness changes, and checks the full execution configuration under
the lifecycle write lock. It uses the same configuration, company, credential,
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import type { Db } from "@paperclipai/db";
import type { AgentConfigurationRecord } from "../lib/agent-records.js";
import { prepareAgentConfiguration } from "../services/agent-configuration.js";
const existing = { id: "agent", companyId: "company", status: "idle", budgetMonthlyCents: 500 } as AgentConfigurationRecord;
describe("budget authority in configuration preparation", () => {
it("rejects agent-originated budget changes before persistence or policy audit", async () => {
const db = {} as Db;
await expect(prepareAgentConfiguration(db, existing, { budgetMonthlyCents: 5000 }, { recordRevision: { createdByAgentId: "ceo" } })).rejects.toMatchObject({ status: 403 });
await expect(prepareAgentConfiguration(db, existing, { budgetMonthlyCents: 0 }, { recordRevision: { createdByAgentId: "ceo" } })).rejects.toMatchObject({ status: 403 });
});
it("removes an unchanged legacy budget from agent rollback so it cannot rewrite policy or invent a board actor", async () => {
expect(await prepareAgentConfiguration({} as Db, existing, { budgetMonthlyCents: 500 }, { recordRevision: { createdByAgentId: "ceo", source: "rollback" } })).toEqual({});
});
it("preserves the actual board-authorized amount for the existing policy writer", async () => {
expect(await prepareAgentConfiguration({} as Db, existing, { budgetMonthlyCents: 5000 }, { recordRevision: { createdByUserId: "operator-user" } })).toEqual({ budgetMonthlyCents: 5000 });
});
});
@@ -185,11 +185,11 @@ describeEmbeddedPostgres("agent hire idempotency within a run", () => {
// Same identity, corrected configuration: the agent meant a different hire.
const corrected = await request(app)
.post(`/api/companies/${company.id}/agent-hires`)
.send({ name: "Sam", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 });
.send({ name: "Sam", role: "engineer", adapterType: "process", title: "Store Builder" });
expect(corrected.status, JSON.stringify(corrected.body)).toBe(201);
expect(corrected.body.idempotent).toBeUndefined();
expect(corrected.body.agent?.id).not.toBe(first.body.agent?.id);
expect(corrected.body.agent?.budgetMonthlyCents).toBe(5000);
expect(corrected.body.agent?.title).toBe("Store Builder");
});
it("still creates a distinct agent for a different hire in the same run", async () => {
@@ -2370,6 +2370,48 @@ describe("agent permission routes", () => {
});
});
it.each([0, 5000])("budget authority: rejects agent policy changes through the legacy field (%i)", async amount => {
const app = createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" });
const response = await requestApp(app, url => request(url).patch(`/api/agents/${agentId}`).send({ budgetMonthlyCents: amount }));
expect(response.status).toBe(403);
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it.each(["agents", "agent-hires"])("budget authority: rejects agent immediate creation budgets through %s", async route => {
mockAccessService.hasPermission.mockResolvedValue(true);
const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" });
const response = await requestApp(app, url => request(url).post(`/api/companies/${companyId}/${route}`).send({ name: "New employee", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 }));
expect(response.status).toBe(403);
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("budget authority: preserves a proposed budget without applying a policy before approval", async () => {
mockAccessService.hasPermission.mockResolvedValue(true);
mockAgentService.create.mockResolvedValue({ ...baseAgent, status: "pending_approval", budgetMonthlyCents: 5000 });
mockApprovalService.create.mockResolvedValue({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "pending", type: "hire_agent", companyId });
const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" }, { requireBoardApprovalForNewAgents: true });
const response = await requestApp(app, url => request(url).post(`/api/companies/${companyId}/agent-hires`).send({ name: "Proposed employee", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 }));
expect(response.status).toBe(201);
expect(response.body.approval.status).toBe("pending");
expect(mockApprovalService.create).toHaveBeenCalledWith(companyId, expect.objectContaining({ payload: expect.objectContaining({ budgetMonthlyCents: 5000 }) }));
expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled();
});
it("budget authority: forwards the actual board identity on legacy updates", async () => {
const app = createApp({ type: "board", userId: "operator-user", source: "session", companyIds: [companyId] });
const response = await requestApp(app, url => request(url).patch(`/api/agents/${agentId}`).send({ budgetMonthlyCents: 5000 }));
expect(response.status).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(agentId, expect.objectContaining({ budgetMonthlyCents: 5000 }), expect.objectContaining({ recordRevision: expect.objectContaining({ createdByUserId: "operator-user", createdByAgentId: null }) }));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ actorType: "user", actorId: "operator-user", agentId: null }));
});
it.each(["agents", "agent-hires"])("budget authority: records the real board actor for immediate %s budgets", async route => {
mockAgentService.create.mockResolvedValue({ ...baseAgent, budgetMonthlyCents: 5000 });
const app = createApp({ type: "board", userId: "operator-user", source: "local_implicit", companyIds: [companyId] });
const response = await requestApp(app, url => request(url).post(`/api/companies/${companyId}/${route}`).send({ name: "New employee", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 }));
expect(response.status).toBe(201);
expect(mockBudgetService.upsertPolicy).toHaveBeenCalledWith(companyId, expect.objectContaining({ scopeType: "agent", scopeId: agentId, amount: 5000 }), "operator-user");
});
it("rejects heartbeat cancellation outside the caller company scope", async () => {
mockHeartbeatService.getRun.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+24 -10
View File
@@ -4292,6 +4292,9 @@ export function agentRoutes(
if (!rollbackConfig) {
throw unprocessable("Invalid revision snapshot");
}
if (typeof rollbackConfig.budgetMonthlyCents === "number" && rollbackConfig.budgetMonthlyCents !== existing.budgetMonthlyCents) {
assertBoard(req);
}
assertProviderTraceSettingTransition(
req,
rollbackConfig.runtimeConfig,
@@ -4424,6 +4427,17 @@ export function agentRoutes(
const handleAgentHire = async (req: Request, res: Response) => {
const companyId = req.params.companyId as string;
await assertCanCreateAgentsForCompany(req, companyId);
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
if (!company.requireBoardApprovalForNewAgents && req.body.budgetMonthlyCents > 0) assertBoard(req);
const sourceIssueIds = parseSourceIssueIds(req.body);
const {
desiredSkills: requestedDesiredSkills,
@@ -4587,16 +4601,6 @@ export function agentRoutes(
allowedSandboxProviders: allowedSandboxProvidersForAgent(hireInput.adapterType),
});
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
// Idempotency within a run: if this run already created a hire from this
// exact request, return that hire instead of creating a duplicate. The
// creating agent cannot pause or delete its own hire (board-only), so a
@@ -4683,6 +4687,14 @@ export function agentRoutes(
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
const actor = getActorInfo(req);
if (!requiresApproval && agent.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(companyId, {
scopeType: "agent",
scopeId: agent.id,
amount: agent.budgetMonthlyCents,
windowKind: "calendar_month_utc",
}, actor.actorId);
}
if (requiresApproval) {
const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType;
@@ -4808,6 +4820,7 @@ export function agentRoutes(
router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanCreateAgentsForCompany(req, companyId);
if (req.body.budgetMonthlyCents > 0) assertBoard(req);
const company = await db
.select()
@@ -5478,6 +5491,7 @@ export function agentRoutes(
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
if (!existing) return;
if (hasOwn(req.body as object, "budgetMonthlyCents")) assertBoard(req);
if (hasOwn(req.body as object, "permissions")) {
res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" });
+5 -1
View File
@@ -10,7 +10,7 @@ import { and, eq } from "drizzle-orm";
import { assets, agentConfigRevisions, agentTaskSessions, agentRuntimeState, type Db } from "@paperclipai/db";
import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils";
import { conflict, notFound, unprocessable } from "../errors.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { agentRecordQueries, type UpdateAgentOptions, type AgentConfigurationPatch, type AgentConfigurationRecord, isPlainRecord, buildConfigSnapshot,
hasConfigPatchFields, changedPendingApprovalConfigFields, diffConfigSnapshot } from "../lib/agent-records.js";
import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
@@ -21,6 +21,9 @@ import { clearPrimaryAgent } from "./primary-agent.js";
import { budgetServiceInTransaction } from "./budgets.js";
export async function prepareAgentConfiguration(db: Db, existing: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch> {
if (options?.recordRevision?.createdByAgentId && data.budgetMonthlyCents !== undefined && data.budgetMonthlyCents !== existing.budgetMonthlyCents) {
throw forbidden("Only board operators can change agent budgets");
}
await notifyAttentionRetentionWork(db, { companyId: existing.companyId });
const id = existing.id;
const { ensureManager, assertNoCycle, assertCompanyShortnameAvailable, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db);
@@ -64,6 +67,7 @@ export async function prepareAgentConfiguration(db: Db, existing: AgentConfigura
}
}
const normalizedPatch = { ...data } as AgentConfigurationPatch;
if (options?.recordRevision?.createdByAgentId) delete normalizedPatch.budgetMonthlyCents;
if (data.permissions !== undefined) {
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions);
}