fix(server): authorize agent resume through direct grants (#12047)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip keeps agent lifecycle changes behind control-plane
authorization
> - Plugins can create agents in a paused state until an operator
activates them
> - An agent with a direct configuration grant could not resume these
agents
> - A paused plugin-managed agent also had no stable provenance in its
pause reason
> - This pull request adds one protected resume path and preserves every
other lifecycle gate
> - The benefit is safe recovery from plugin provisioning without a
broad permission change

## Linked Issues or Issue Description

Refs #8168. That pull request uses a role capability and also opens
clear-error. This change uses the current grant system and keeps
clear-error closed.

**What happened?**

A plugin can create a paused managed agent. An agent actor cannot resume
that agent, even when the actor has a direct `agents:configure` grant.
The paused agent can also have a null pause reason.

**Expected behavior**

An agent with a direct `agents:configure` grant can resume an accessible
paused agent. An agent without that grant cannot resume it.
Plugin-managed paused agents show stable plugin provenance. A completed
resume stays in effect after reconcile.

**Steps to reproduce**

1. Install a plugin that declares a managed agent with `status: paused`.
2. Give a same-company agent a direct `agents:configure` grant.
3. Call `POST /api/agents/{id}/resume` with the granted agent key.
4. On the base revision, observe a board-only authorization error.

**Paperclip version or commit**

`master` at `63df7ad2b3`.

**Deployment mode**

All deployment modes. This is a server authorization and reconcile
behavior.

## What Changed

- The resume route now uses the protected `agent_config:update` decision
with `requiresChangeGrant: true` for agent actors.
- The route keeps board access, tenant non-disclosure, and invalid
organization-chain protection.
- Resume activity now records the real user or agent actor, run, and API
key.
- Plugin-managed paused agents now receive a stable provenance reason
and pause time at creation.
- Reconcile backfills only a null reason on an agent that is still
declared and stored as paused.
- Reconcile preserves manual, budget, system, and other pause reasons.
It does not pause a resumed agent again.
- The implementation specification now records the narrow resume
exception.

## Verification

- `pnpm exec vitest run
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
server/src/__tests__/plugin-managed-agents.test.ts` passed: 2 files and
26 tests.
- `pnpm --filter @paperclipai/server typecheck` passed.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- GitHub CI passed all policy, typecheck, build, test, e2e, canary, and
security gates on commit `306edf469c`.
- Greptile reviewed all 5 changed files. Its check passed with 0
comments and 0 unresolved threads.
- The host uses Node 22.22.2. The repository requests Node 24.11 or
newer, so pnpm printed engine warnings.
- A broad `pnpm test:run` attempt did not complete its general-server
group. Runtime port fixtures failed because host port `52000` was
already bound. The isolated failing fixture reproduced the same port
conflict. The focused feature tests passed before and after the final
commit.

## Risks

The main risk is an unintended lifecycle permission increase. The change
limits agent access to resume only. It requires a protected
direct-change decision. It does not open pause, clear-error, terminate,
approval, or key-management routes. Tests cover denial, self-denial,
tenant isolation, organization-chain checks, and activity attribution.
There is no database migration.

> This change fixes a narrow gap in the completed plugin, approval, and
activity-log roadmap areas. It does not add a new roadmap feature.

## Model Used

OpenAI Codex `gpt-5.6-sol`, with xhigh reasoning, tool use, and code
execution. The runtime did not expose its context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-23 16:15:32 -05:00 committed by GitHub
parent 63df7ad2b3
commit ae6761e2b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 386 additions and 10 deletions

View File

@ -558,7 +558,7 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
|---|---|---|
| Create company | yes | no |
| Hire/create agent | yes (direct) | request via approval |
| Pause/resume agent | yes | no |
| Pause/resume agent | yes | pause: no; resume: direct `agents:configure` grant only |
| Create/update task | yes | yes |
| Force reassign task | yes | limited |
| Approve strategy/hire requests | yes | no |
@ -569,6 +569,13 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
| Manage another user's inbox state | yes | saved target-user opt-in or scoped `inbox:manage` grant |
| Set work-object visibility (issue/project) | no | no (pro gate) |
Agent resume is the only grant-gated exception in the lifecycle-route group. An
agent actor calling `POST /agents/:agentId/resume` must pass the protected
`agent_config:update` decision with `scope.requiresChangeGrant: true`; self
access does not bypass that decision, and `agents:suggest-changes` alone cannot
apply the lifecycle change. Pause, clear-error, terminate, approval, and
key-management routes remain board-only.
### 9.3.1 Shared default-open issue writes
For standard-trust agents, issue comments, issue field/status updates, child

View File

@ -180,6 +180,7 @@ vi.mock("../routes/authz.js", async () => {
actorId: req.actor.agentId ?? "unknown-agent",
agentId: req.actor.agentId ?? null,
runId: req.actor.runId ?? null,
agentApiKeyId: req.actor.keyId ?? null,
};
}
return {
@ -187,6 +188,7 @@ vi.mock("../routes/authz.js", async () => {
actorId: req.actor.userId ?? "board",
agentId: null,
runId: req.actor.runId ?? null,
agentApiKeyId: null,
};
}
@ -437,6 +439,220 @@ describe.sequential("agent cross-tenant route authorization", () => {
expect(mockAgentService.clearError).not.toHaveBeenCalled();
});
it("preserves board resume access", async () => {
const pausedAgent = { ...baseAgent, status: "paused", pauseReason: "manual", pausedAt: new Date() };
mockAgentService.getById.mockResolvedValue(pausedAgent);
mockAgentService.resume.mockResolvedValue({
...pausedAgent,
status: "idle",
pauseReason: null,
pausedAt: null,
});
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "local_implicit",
isInstanceAdmin: true,
});
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
);
expect(res.status).toBe(200);
expect(mockAgentService.resume).toHaveBeenCalledWith(agentId);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId,
actorType: "user",
actorId: "board-user",
agentId: null,
runId: null,
agentApiKeyId: null,
action: "agent.resumed",
entityType: "agent",
entityId: agentId,
}));
});
it("allows a same-company agent with a direct agents:configure grant to resume", async () => {
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "agent_config:update",
reason: "allow_direct_change",
explanation: "Allowed by direct configuration grant.",
grant: { permissionKey: "agents:configure" },
});
const app = await createApp({
type: "agent",
agentId: "44444444-4444-4444-8444-444444444444",
companyId,
runId: "55555555-5555-4555-8555-555555555555",
keyId: "66666666-6666-4666-8666-666666666666",
source: "agent_key",
});
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
);
expect(res.status).toBe(200);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "agent_config:update",
resource: { type: "agent", companyId, agentId },
scope: { requiresChangeGrant: true },
}));
expect(mockAgentService.resume).toHaveBeenCalledWith(agentId);
});
it.each([
["an ungranted peer", "44444444-4444-4444-8444-444444444444"],
["an ungranted self", agentId],
])("denies resume for %s", async (_label, actorAgentId) => {
mockAccessService.decide.mockResolvedValue({
allowed: false,
action: "agent_config:update",
reason: "deny_no_grant",
explanation: "No direct agent configuration grant.",
});
const app = await createApp({
type: "agent",
agentId: actorAgentId,
companyId,
runId: "55555555-5555-4555-8555-555555555555",
source: "agent_key",
});
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
);
expect(res.status).toBe(403);
expect(res.body).toMatchObject({
error: "No direct agent configuration grant.",
details: { reason: "deny_no_grant" },
});
expect(mockAgentService.resume).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("denies resume when the agent only has agents:suggest-changes", async () => {
mockAccessService.decide.mockResolvedValue({
allowed: false,
action: "agent_config:update",
reason: "deny_missing_consent",
explanation: "Accepted consent is required for this suggested change.",
grant: { permissionKey: "agents:suggest-changes" },
});
const app = await createApp({
type: "agent",
agentId: "44444444-4444-4444-8444-444444444444",
companyId,
runId: "55555555-5555-4555-8555-555555555555",
source: "agent_key",
});
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
);
expect(res.status).toBe(403);
expect(res.body.details).toEqual({ reason: "deny_missing_consent" });
expect(mockAgentService.resume).not.toHaveBeenCalled();
});
it("does not disclose a cross-company resume target", async () => {
const app = await createApp({
type: "agent",
agentId: "44444444-4444-4444-8444-444444444444",
companyId: "77777777-7777-4777-8777-777777777777",
runId: "55555555-5555-4555-8555-555555555555",
source: "agent_key",
});
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
);
expect(res.status).toBe(404);
expect(res.body.error).toBe("Agent not found");
expect(mockAccessService.decide).not.toHaveBeenCalled();
expect(mockAgentService.resume).not.toHaveBeenCalled();
});
it("keeps the invalid-org-chain guard for granted agent resume", async () => {
mockAgentService.getById.mockResolvedValue({
...baseAgent,
status: "paused",
orgChainHealth: {
status: "invalid_org_chain",
reason: "missing_manager",
repairGuidance: "Repair the reporting chain first.",
},
});
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "agent_config:update",
reason: "allow_direct_change",
explanation: "Allowed by direct configuration grant.",
grant: { permissionKey: "agents:configure" },
});
const app = await createApp({
type: "agent",
agentId: "44444444-4444-4444-8444-444444444444",
companyId,
runId: "55555555-5555-4555-8555-555555555555",
source: "agent_key",
});
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
);
expect(res.status).toBe(409);
expect(res.body.error).toBe("Repair the reporting chain first.");
expect(mockAgentService.resume).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("attributes agent resume activity to the acting agent, run, and API key", async () => {
const actorAgentId = "44444444-4444-4444-8444-444444444444";
const runId = "55555555-5555-4555-8555-555555555555";
const actorKeyId = "66666666-6666-4666-8666-666666666666";
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "agent_config:update",
reason: "allow_direct_change",
explanation: "Allowed by direct configuration grant.",
grant: { permissionKey: "agents:configure" },
});
const app = await createApp({
type: "agent",
agentId: actorAgentId,
companyId,
runId,
keyId: actorKeyId,
source: "agent_key",
});
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}),
);
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), {
companyId,
actorType: "agent",
actorId: actorAgentId,
agentId: actorAgentId,
runId,
agentApiKeyId: actorKeyId,
action: "agent.resumed",
entityType: "agent",
entityId: agentId,
});
});
it("clears error agents and records a distinct audit action", async () => {
const errorAgent = {
...baseAgent,

View File

@ -22,6 +22,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { agentService } from "../services/agents.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -69,6 +70,15 @@ function manifest(): PaperclipPluginManifestV1 {
};
}
function pausedManifest(): PaperclipPluginManifestV1 {
const pluginManifest = manifest();
pluginManifest.agents![0] = {
...pluginManifest.agents![0]!,
status: "paused",
};
return pluginManifest;
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
@ -190,6 +200,64 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
});
});
it("records plugin provenance when a manifest creates a paused managed agent", async () => {
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(created.agent).toMatchObject({
status: "paused",
pauseReason: "Provisioned paused by plugin paperclip.managed-agents-test; requires explicit activation.",
});
expect(created.agent?.pausedAt).toBeInstanceOf(Date);
});
it("backfills a legacy null pause reason while the managed declaration and agent remain paused", async () => {
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
await db
.update(agents)
.set({ pauseReason: null, updatedAt: new Date() })
.where(eq(agents.id, created.agentId!));
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(reconciled.agent).toMatchObject({
status: "paused",
pauseReason: "Provisioned paused by plugin paperclip.managed-agents-test; requires explicit activation.",
});
});
it.each(["manual", "budget", "system", "maintenance"])(
"preserves the existing %s pause reason during reconcile",
async (pauseReason) => {
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
await db
.update(agents)
.set({ pauseReason, updatedAt: new Date() })
.where(eq(agents.id, created.agentId!));
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(reconciled.agent).toMatchObject({ status: "paused", pauseReason });
},
);
it("keeps an explicit resume durable across managed-agent reconcile", async () => {
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
await agentService(db).resume(created.agentId!);
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
expect(reconciled.agent).toMatchObject({
status: "idle",
pauseReason: null,
pausedAt: null,
});
});
it("creates managed agents with the most-used compatible company adapter", async () => {
const pluginManifest = manifest();
pluginManifest.agents![0] = {

View File

@ -2133,6 +2133,22 @@ export function agentRoutes(
);
}
async function assertCanResumeAgent(
req: Request,
targetAgent: { id: string; companyId: string },
) {
if (req.actor.type !== "agent") return;
const decision = await access.decide({
actor: req.actor,
action: "agent_config:update",
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
scope: { requiresChangeGrant: true },
});
if (decision.allowed) return;
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
function assertNoAgentInstructionsConfigMutation(
req: Request,
adapterConfig: Record<string, unknown> | null | undefined,
@ -4111,12 +4127,12 @@ export function agentRoutes(
});
router.post("/agents/:id/resume", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await getAccessibleAgent(req, res, id);
if (!existing) {
return;
}
await assertCanResumeAgent(req, existing);
if (existing.orgChainHealth?.status === "invalid_org_chain") {
res.status(409).json({
error: existing.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before resuming it",
@ -4129,10 +4145,14 @@ export function agentRoutes(
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "agent.resumed",
entityType: "agent",
entityId: agent.id,

View File

@ -1,4 +1,4 @@
import { and, eq, ne } from "drizzle-orm";
import { and, eq, isNull, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
@ -21,6 +21,10 @@ import { agentInstructionsService } from "./agent-instructions.js";
const MANAGED_AGENT_ENTITY_TYPE = "managed_agent";
const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process";
function managedAgentPauseReason(pluginKey: string) {
return `Provisioned paused by plugin ${pluginKey}; requires explicit activation.`;
}
interface PluginManagedAgentServiceOptions {
pluginId: string;
pluginKey: string;
@ -416,9 +420,12 @@ export function pluginManagedAgentService(
const requiresApproval = company.requireBoardApprovalForNewAgents;
const adapterType = await resolveManagedAdapterType(companyId, declaration);
const initialStatus = requiresApproval ? "pending_approval" : declaration.status ?? "idle";
let created = await agentSvc.create(companyId, {
...declarationPatch(declaration, { adapterType }),
status: requiresApproval ? "pending_approval" : declaration.status ?? "idle",
status: initialStatus,
pauseReason: initialStatus === "paused" ? managedAgentPauseReason(options.pluginKey) : null,
pausedAt: initialStatus === "paused" ? new Date() : null,
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration),
spentMonthlyCents: 0,
lastHeartbeatAt: null,
@ -490,6 +497,57 @@ export function pluginManagedAgentService(
return resolution(companyId, declaration, created as Agent, "created", approvalId);
}
async function backfillManagedPauseReason(
companyId: string,
declaration: PluginManagedAgentDeclaration,
agent: Agent,
) {
if (
declaration.status !== "paused"
|| agent.status !== "paused"
|| agent.pauseReason !== null
) {
return agent;
}
const updated = await db
.update(agents)
.set({
pauseReason: managedAgentPauseReason(options.pluginKey),
updatedAt: new Date(),
})
.where(and(
eq(agents.id, agent.id),
eq(agents.companyId, companyId),
eq(agents.status, "paused"),
isNull(agents.pauseReason),
))
.returning({ id: agents.id })
.then((rows) => rows[0] ?? null);
if (!updated) {
const current = await agentSvc.getById(agent.id) as Agent | null;
return current ?? agent;
}
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_agent.pause_reason_backfilled",
entityType: "agent",
entityId: updated.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
pauseReason: managedAgentPauseReason(options.pluginKey),
},
});
const refreshed = await agentSvc.getById(updated.id) as Agent | null;
return refreshed ?? agent;
}
async function get(agentKey: string, companyId: string) {
const declaration = declarationFor(agentKey);
const binding = await getBinding(companyId, agentKey);
@ -506,15 +564,22 @@ export function pluginManagedAgentService(
const declaration = declarationFor(agentKey);
const current = await get(agentKey, companyId);
if (current.agent) {
await upsertBinding(companyId, declaration, current.agent.id);
return current;
const agent = await backfillManagedPauseReason(companyId, declaration, current.agent);
await upsertBinding(companyId, declaration, agent.id);
return resolution(companyId, declaration, agent, current.status, current.approvalId);
}
const relinkCandidate = await findRelinkCandidate(companyId, declaration);
if (relinkCandidate) {
await upsertBinding(companyId, declaration, relinkCandidate.id);
const agent = await agentSvc.getById(relinkCandidate.id);
return resolution(companyId, declaration, agent as Agent, "relinked");
const relinkedAgent = await agentSvc.getById(relinkCandidate.id) as Agent | null;
if (!relinkedAgent) throw notFound("Managed agent not found");
const agent = await backfillManagedPauseReason(
companyId,
declaration,
relinkedAgent,
);
return resolution(companyId, declaration, agent, "relinked");
}
return createManagedAgent(companyId, declaration);