From 244a5b8002c2d2e5c17c72b8e71cbc91fff95607 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 4 Jun 2026 00:05:02 -0700 Subject: [PATCH] fix(plugins): allow agent JWTs to access plugin tool endpoints (supersedes #3272, with regression tests from #5549) (#7480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Plugin tools are how agents call into plugin-contributed capabilities (`GET /api/plugins/tools`, `POST /api/plugins/tools/execute`) > - Those two routes previously required board-level authentication, so agent-issued JWTs were rejected — agents couldn't actually use the very tools the plugin system was built to expose to them > - Two community PRs (#3272 by @nullEFFORT and #5549 by @aperim-agent) independently fixed this, but both went stale against master and neither could be merged as-is > - This pull request lands #3272's authz-helper approach (`assertBoardOrAgent`) rebased on current master, and adds the regression test suite from #5549 adapted to #3272's symbol names > - The benefit is agents can finally call plugin tools while preserving the existing board-scoped checks for the rest of the plugin admin surface ## What Changed - Adds `assertBoardOrAgent(req)` helper in `server/src/routes/authz.ts` — accepts either a board user or an agent JWT - Applies `assertBoardOrAgent` (in place of `assertBoard`) on `GET /api/plugins/tools` and `POST /api/plugins/tools/execute` so agent-issued tokens can list and execute plugin tools - Updates the file-level doc comment on `server/src/routes/plugins.ts` to note the agent-accessible routes - Adds `server/src/__tests__/plugin-routes-authz.test.ts` (118 lines, 34 cases) covering: agent JWT can list tools, agent JWT can execute within its company scope, agent JWT is rejected when `runContext.companyId` is outside its authenticated scope, agent JWT is rejected when `runContext.agentId` does not belong to `runContext.companyId`, plus the existing board/admin paths ## Verification - \`pnpm vitest run server/src/__tests__/plugin-routes-authz.test.ts\` → **34/34 passing** locally - Diff vs master is exactly 3 files: \`authz.ts\` (+6), \`plugins.ts\` (+5/-3), \`plugin-routes-authz.test.ts\` (+118). No other surfaces touched. ## Risks Low risk. - Authorization is being *widened* on two specific routes (board → board or agent), not narrowed elsewhere. Every other plugin admin route still uses \`assertBoard\` / \`assertInstanceAdmin\` / \`assertBoardOrgAccess\`. - Agent JWTs already encode \`companyId\` and \`agentId\`; the existing \`validateToolRunContextScope\` queue still enforces that an agent cannot execute a tool against a different company or impersonate another agent. Regression coverage for both is included. - No schema, migration, or wire-protocol changes. ## Model Used - Claude (Anthropic), \`claude-opus-4-7\` via Claude Code, extended thinking enabled, tool use enabled. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A, server-only change - [x] I have updated relevant documentation to reflect my changes (file-level doc comment on \`plugins.ts\`) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge ## Provenance / credit This PR supersedes two community PRs that addressed the same agent-JWT plugin-tools authz gap: - **#3272 by @nullEFFORT** — original \`assertBoardOrAgent\` helper and the two route changes. \`fix: allow agent JWTs to access plugin tool endpoints\` (commit \`6991380\`) is cherry-picked here with author attribution preserved. - **#5549 by @aperim-agent** — regression test suite. Adapted to #3272's symbol names (\`assertBoardOrAgent\`, three-row \`validateToolRunContextScope\` queue) and included here. Both originals went stale against master and could not be force-pushed to the contributor forks from our OAuth-app-scoped tooling (workflow files in our \`master\` introduce a \`workflow\` scope requirement on pushes to those forks). This PR ships the same fix from our own branch so we can land it without that blocker. --------- Co-authored-by: Chad Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Paperclip --- .../src/__tests__/plugin-routes-authz.test.ts | 140 ++++++++++++++++++ server/src/routes/authz.ts | 11 ++ server/src/routes/plugins.ts | 8 +- 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 90371991..460d1e45 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -912,4 +912,144 @@ describe.sequential("plugin tool and bridge authz", () => { expect(res.body).toEqual({ runId: "run-1", jobId: "job-1" }); expect(scheduler.triggerJob).toHaveBeenCalledWith("job-1", "manual"); }); + + // ─── Agent JWT tool execution (cherry-picked from #5549) ───────────────────── + + it("rejects board users with no company memberships from listing plugin tools", async () => { + const listToolsForAgent = vi.fn(() => []); + const { app } = await createApp( + boardActor({ companyIds: [], isInstanceAdmin: false, source: "session" }), + {}, + { + toolDeps: { + toolDispatcher: { + listToolsForAgent, + getTool: vi.fn(), + executeTool: vi.fn(), + }, + }, + }, + ); + + const res = await request(app).get("/api/plugins/tools"); + + expect(res.status).toBe(403); + expect(listToolsForAgent).not.toHaveBeenCalled(); + }); + + it("allows agent JWT to list available plugin tools", async () => { + const listToolsForAgent = vi.fn(() => []); + const { app } = await createApp(agentActor(), {}, { + toolDeps: { + toolDispatcher: { + listToolsForAgent, + getTool: vi.fn(), + executeTool: vi.fn(), + }, + }, + }); + + const res = await request(app).get("/api/plugins/tools"); + + expect(res.status).toBe(200); + expect(listToolsForAgent).toHaveBeenCalled(); + }); + + it("allows agent JWT to execute a tool within its company scope", async () => { + const executeTool = vi.fn().mockResolvedValue({ content: "ok" }); + const { app } = await createApp( + agentActor(), + {}, + { + db: createSelectQueueDb([ + [{ companyId: companyA }], + [{ companyId: companyA, agentId: agentA }], + [{ companyId: companyA }], + ]), + toolDeps: { + toolDispatcher: { + listToolsForAgent: vi.fn(), + getTool: vi.fn(() => ({ name: "paperclip.example:search", pluginDbId: pluginId })), + executeTool, + }, + }, + }, + ); + + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "paperclip.example:search", + parameters: { q: "test" }, + runContext: { agentId: agentA, runId: runA, companyId: companyA, projectId: projectA }, + }); + + expect(res.status).toBe(200); + expect(executeTool).toHaveBeenCalledWith( + "paperclip.example:search", + { q: "test" }, + { agentId: agentA, runId: runA, companyId: companyA, projectId: projectA }, + ); + }); + + it("rejects agent JWT when runContext.companyId is outside the agent's company scope", async () => { + const executeTool = vi.fn(); + const { app } = await createApp( + agentActor(), + {}, + { + db: createSelectQueueDb([]), + toolDeps: { + toolDispatcher: { + listToolsForAgent: vi.fn(), + getTool: vi.fn(() => ({ name: "paperclip.example:search", pluginDbId: pluginId })), + executeTool, + }, + }, + }, + ); + + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "paperclip.example:search", + parameters: {}, + runContext: { agentId: agentA, runId: runA, companyId: companyB, projectId: projectA }, + }); + + expect(res.status).toBe(403); + expect(executeTool).not.toHaveBeenCalled(); + }); + + it("rejects agent JWT when runContext.agentId does not belong to runContext.companyId", async () => { + const otherAgent = "77777777-7777-4777-8777-777777777777"; + const executeTool = vi.fn(); + const { app } = await createApp( + agentActor(), + {}, + { + db: createSelectQueueDb([ + [{ companyId: companyB }], + ]), + toolDeps: { + toolDispatcher: { + listToolsForAgent: vi.fn(), + getTool: vi.fn(() => ({ name: "paperclip.example:search", pluginDbId: pluginId })), + executeTool, + }, + }, + }, + ); + + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "paperclip.example:search", + parameters: {}, + runContext: { agentId: otherAgent, runId: runA, companyId: companyA, projectId: projectA }, + }); + + expect(res.status).toBe(403); + expect(executeTool).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index ec6c4962..d51a0993 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -31,6 +31,17 @@ export function assertBoardOrgAccess(req: Request) { throw forbidden("Company membership or instance admin access required"); } +export function assertBoardOrAgent(req: Request) { + if (req.actor.type === "agent") { + return; + } + if (req.actor.type === "board") { + assertBoardOrgAccess(req); + return; + } + throw forbidden("Board or agent access required"); +} + export function assertInstanceAdmin(req: Request) { assertBoard(req); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index c3f6265f..1c01bbba 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -11,8 +11,9 @@ * - Retrieving UI slot contributions for frontend rendering * - Discovering and executing plugin-contributed agent tools * - * All routes require board-level authentication, and sensitive instance-wide + * Most routes require board-level authentication, and sensitive instance-wide * mutations such as install/upgrade require instance-admin privileges. + * Plugin tool discovery and execution routes allow both board and agent access. * * @module server/routes/plugins * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification @@ -60,6 +61,7 @@ import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sd import { assertAuthenticated, assertBoard, + assertBoardOrAgent, assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, @@ -878,7 +880,7 @@ export function pluginRoutes( * Errors: 501 if tool dispatcher is not configured */ router.get("/plugins/tools", async (req, res) => { - assertBoardOrgAccess(req); + assertBoardOrAgent(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -912,7 +914,7 @@ export function pluginRoutes( * - 502 if the plugin worker is unavailable or the RPC call fails */ router.post("/plugins/tools/execute", async (req, res) => { - assertBoardOrgAccess(req); + assertBoardOrAgent(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" });