fix(agents): keep agent termination board-only on the PATCH status route

PATCH /api/agents/:id with status "terminated" ran the lifecycle terminate
command after only the agent-config update check, so an agent that could
configure a peer (or itself) could terminate it after POST /terminate
returned 403. Both routes now share one board-only gate whose denial names
where a person can terminate the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-07 22:32:43 -06:00
1 parent 031aab4041
commit fd29465e1a
2 files changed
+33 -1

No files matched your search

@@ -1708,6 +1708,30 @@ describe("agent permission routes", () => {
expect(mockAgentService.terminate).not.toHaveBeenCalled();
});
it.each([
["PATCH status", (base: string) => request(base).patch(`/api/agents/${agentId}`).send({ status: "terminated" })],
["POST terminate", (base: string) => request(base).post(`/api/agents/${agentId}/terminate`).send({})],
])("keeps agent termination board-only through %s, even for an agent allowed to configure the target", async (_route, send) => {
mockAgentService.getById.mockResolvedValue({ ...baseAgent, status: "idle" });
const app = await createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await requestApp(app, send);
expect(res.status).toBe(403);
expect(res.body.error).toContain("only a board user can terminate an agent");
expect(res.body.error).toContain("agent's profile");
expect(mockAgentService.terminate).not.toHaveBeenCalled();
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelInvocationsForAgents).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("terminates directly when no open hire approval is linked", async () => {
const idleAgent = { ...baseAgent, status: "idle" };
const terminatedAgent = { ...baseAgent, status: "terminated" };
+9 -1
View File
@@ -1777,6 +1777,12 @@ export function agentRoutes(
};
}
/** Terminating an agent is a board-only governed action; tell other actors where a person can do it. */
function assertBoardCanTerminateAgent(req: Request) {
if (req.actor.type === "board") return;
throw forbidden("Board access required: only a board user can terminate an agent, using Terminate in the actions menu on the agent's profile.");
}
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
if (!hasCompanyAccess(req, targetAgent.companyId)) {
throw notFound("Agent not found");
@@ -5506,6 +5512,8 @@ export function agentRoutes(
const requestedStatus = patchData.status;
delete patchData.status;
if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status");
// Termination is board-only and irreversible on every route, not just POST /terminate.
if (requestedStatus === "terminated") assertBoardCanTerminateAgent(req);
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
delete patchData.replaceAdapterConfig;
// The apply-existing flag is not an agent column. The server binds the fixed
@@ -5943,7 +5951,7 @@ export function agentRoutes(
});
router.post("/agents/:id/terminate", async (req, res) => {
assertBoard(req);
assertBoardCanTerminateAgent(req);
const id = req.params.id as string;
const existing = await getAccessibleAgent(req, res, id);
if (!existing) {