From 6f416935a099d0de2547b2f69a89a23e4b564657 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 22 May 2026 12:44:49 +0000 Subject: [PATCH] Harden plugin action invocation scope --- .../plugins/sdk/src/host-client-factory.ts | 70 ++++++++- packages/plugins/sdk/src/index.ts | 6 + packages/plugins/sdk/src/protocol.ts | 41 +++++ packages/plugins/sdk/src/testing.ts | 71 ++++++++- packages/plugins/sdk/src/types.ts | 8 +- packages/plugins/sdk/src/worker-rpc-host.ts | 81 ++++++++-- .../sdk/tests/host-client-factory.test.ts | 79 ++++++++++ .../plugins/sdk/tests/testing-actions.test.ts | 74 +++++++++ .../plugin-worker-invocation-scope.cjs | 100 ++++++++++++ .../src/__tests__/plugin-routes-authz.test.ts | 138 +++++++++++++++++ .../__tests__/plugin-worker-manager.test.ts | 144 ++++++++++++++++++ server/src/routes/plugins.ts | 53 ++++++- server/src/services/plugin-worker-manager.ts | 59 ++++++- 13 files changed, 888 insertions(+), 36 deletions(-) create mode 100644 packages/plugins/sdk/tests/host-client-factory.test.ts create mode 100644 packages/plugins/sdk/tests/testing-actions.test.ts create mode 100644 server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 8b85b0b7e1f..e080d33d464 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -73,6 +73,21 @@ export class CapabilityDeniedError extends Error { } } +/** + * Thrown when a worker→host call tries to escape the company scope attached to + * the current host invocation. + */ +export class InvocationScopeDeniedError extends Error { + override readonly name = "InvocationScopeDeniedError"; + readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED; + + constructor(pluginId: string, method: string, requestedCompanyId: string, allowedCompanyId: string) { + super( + `Plugin "${pluginId}" cannot call "${method}" for company "${requestedCompanyId}" during invocation scoped to company "${allowedCompanyId}"`, + ); + } +} + // --------------------------------------------------------------------------- // Host service interfaces // --------------------------------------------------------------------------- @@ -292,8 +307,17 @@ export interface HostClientFactoryOptions { */ type HostHandler = ( params: WorkerToHostMethods[M][0], + context?: HostHandlerContext, ) => Promise; +export interface HostInvocationScope { + companyId?: string | null; +} + +export interface HostHandlerContext { + invocationScope?: HostInvocationScope | null; +} + /** * A complete map of all worker→host method handlers. * @@ -474,6 +498,43 @@ export function createHostClientHandlers( throw new CapabilityDeniedError(pluginId, method, required); } + function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function invocationCompanyId(context: HostHandlerContext | undefined): string | null { + return nonEmptyString(context?.invocationScope?.companyId); + } + + function requestedCompanyId(params: unknown): string | null { + if (!params || typeof params !== "object") return null; + const record = params as Record; + const directCompanyId = nonEmptyString(record.companyId); + if (directCompanyId) return directCompanyId; + if (record.scopeKind === "company") return nonEmptyString(record.scopeId); + return null; + } + + function assertInvocationCompanyScope( + method: WorkerToHostMethodName, + params: unknown, + context: HostHandlerContext | undefined, + ): void { + const allowedCompanyId = invocationCompanyId(context); + if (!allowedCompanyId) return; + const companyId = requestedCompanyId(params); + if (!companyId || companyId === allowedCompanyId) return; + throw new InvocationScopeDeniedError(pluginId, method, companyId, allowedCompanyId); + } + + function filterCompaniesToInvocationScope( + rows: T[], + context: HostHandlerContext | undefined, + ): T[] { + const companyId = invocationCompanyId(context); + return companyId ? rows.filter((row) => row.id === companyId) : rows; + } + /** * Create a capability-gated proxy handler for a method. * @@ -485,9 +546,10 @@ export function createHostClientHandlers( method: M, handler: HostHandler, ): HostHandler { - return async (params: WorkerToHostMethods[M][0]) => { + return async (params: WorkerToHostMethods[M][0], context?: HostHandlerContext) => { requireCapability(method); - return handler(params); + assertInvocationCompanyScope(method, params, context); + return handler(params, context); }; } @@ -591,8 +653,8 @@ export function createHostClientHandlers( }), // Companies - "companies.list": gated("companies.list", async (params) => { - return services.companies.list(params); + "companies.list": gated("companies.list", async (params, context) => { + return filterCompaniesToInvocationScope(await services.companies.list(params), context); }), "companies.get": gated("companies.get", async (params) => { return services.companies.get(params); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 92db6d696cf..f45eb4708dc 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -58,6 +58,7 @@ export { createHostClientHandlers, getRequiredCapability, CapabilityDeniedError, + InvocationScopeDeniedError, } from "./host-client-factory.js"; // JSON-RPC protocol helpers and constants @@ -128,6 +129,8 @@ export type { // JSON-RPC protocol types export type { JsonRpcId, + JsonRpcInvocationScope, + JsonRpcInvocationContext, JsonRpcRequest, JsonRpcSuccessResponse, JsonRpcError, @@ -145,6 +148,9 @@ export type { RunJobParams, GetDataParams, PerformActionParams, + PluginPerformActionActorType, + PluginPerformActionActorContext, + PluginPerformActionContext, ExecuteToolParams, PluginEnvironmentDiagnostic, PluginEnvironmentDriverBaseParams, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 24bc871b369..2e1b69cc449 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -79,6 +79,19 @@ export const JSONRPC_VERSION = "2.0" as const; */ export type JsonRpcId = string | number; +/** + * Host-owned scope attached to a host→worker invocation. Workers may echo the + * invocation id on nested worker→host calls, but they never author this scope. + */ +export interface JsonRpcInvocationScope { + readonly companyId?: string | null; +} + +export interface JsonRpcInvocationContext { + readonly id: string; + readonly scope: JsonRpcInvocationScope; +} + /** * A JSON-RPC 2.0 request message. * @@ -96,6 +109,10 @@ export interface JsonRpcRequest< readonly method: TMethod; /** Structured parameters for the method call. */ readonly params: TParams; + /** Host-owned invocation metadata sent only from host→worker requests. */ + readonly paperclipInvocation?: JsonRpcInvocationContext; + /** Invocation id echoed by SDK worker→host calls made inside that request. */ + readonly paperclipInvocationId?: string; } /** @@ -313,11 +330,35 @@ export interface GetDataParams { * * @see PLUGIN_SPEC.md §13.9 — `performAction` */ +export type PluginPerformActionActorType = "user" | "agent" | "system"; + +export interface PluginPerformActionActorContext { + /** Authenticated principal type resolved by the Paperclip host. */ + type: PluginPerformActionActorType; + /** Authenticated board user id when `type === "user"`, otherwise null. */ + userId: string | null; + /** Authenticated agent id when `type === "agent"`, otherwise null. */ + agentId: string | null; + /** Authenticated heartbeat/run id when available. */ + runId: string | null; + /** Company id authorized by the host bridge for this action, when applicable. */ + companyId: string | null; +} + +export interface PluginPerformActionContext { + /** Immutable authenticated actor context supplied by the host. */ + actor: Readonly; + /** Convenience alias for `actor.companyId`. */ + companyId: string | null; +} + export interface PerformActionParams { /** Plugin-defined action key (e.g. `"resync"`). */ key: string; /** Action parameters from the UI. */ params: Record; + /** Authenticated actor context resolved by the host, never by caller params. */ + actorContext?: PluginPerformActionActorContext | null; /** Optional launcher/container metadata from the host render environment. */ renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index de34e8ae112..63c957deb7b 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -53,6 +53,8 @@ import type { PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginPerformActionActorContext, + PluginPerformActionContext, } from "./protocol.js"; export interface TestHarnessOptions { @@ -70,6 +72,20 @@ export interface TestHarnessLogEntry { meta?: Record; } +export interface TestHarnessPerformActionOptions { + /** + * Authenticated actor context to expose to the action handler. Omitted fields + * default to null, and `type` defaults to `system`. + */ + actor?: Partial | null; + /** + * Host-authorized company scope. When provided, this is injected into + * `params.companyId` so tests match the production bridge's anti-spoofing + * behavior. + */ + companyId?: string | null; +} + export interface TestHarness { /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */ ctx: PluginContext; @@ -92,7 +108,11 @@ export interface TestHarness { /** Invoke a `ctx.data.register(...)` handler by key. */ getData(key: string, params?: Record): Promise; /** Invoke a `ctx.actions.register(...)` handler by key. */ - performAction(key: string, params?: Record): Promise; + performAction( + key: string, + params?: Record, + options?: TestHarnessPerformActionOptions, + ): Promise; /** Execute a registered tool handler via `ctx.tools.execute(...)`. */ executeTool(name: string, params: unknown, runCtx?: Partial): Promise; /** Read raw in-memory state for assertions. */ @@ -452,7 +472,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const jobs = new Map Promise>(); const launchers = new Map(); const dataHandlers = new Map) => Promise>(); - const actionHandlers = new Map) => Promise>(); + const actionHandlers = new Map< + string, + (params: Record, context: PluginPerformActionContext) => Promise + >(); const toolHandlers = new Map Promise>(); function localFolderKey(companyId: string, folderKey: string): string { @@ -463,6 +486,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return `${localFolderKey(companyId, folderKey)}:${relativePath}`; } + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { + return value === "user" || value === "agent" || value === "system" ? value : "system"; + } + + function actionContextFor( + params: Record, + options?: TestHarnessPerformActionOptions, + ): PluginPerformActionContext { + const actorInput = options?.actor ?? null; + const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId); + const actor = Object.freeze({ + type: actorTypeOrSystem(actorInput?.type), + userId: stringOrNull(actorInput?.userId), + agentId: stringOrNull(actorInput?.agentId), + runId: stringOrNull(actorInput?.runId), + companyId, + }); + return Object.freeze({ actor, companyId }); + } + + function paramsWithHostCompanyScope( + params: Record, + context: PluginPerformActionContext, + options?: TestHarnessPerformActionOptions, + ): Record { + if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) { + return context.companyId ? { ...params, companyId: context.companyId } : { ...params }; + } + return params; + } + function normalizeLocalFolderRelativePath(relativePath: string): string { const parts: string[] = []; for (const segment of relativePath.split(/[\\/]+/)) { @@ -2107,10 +2165,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!handler) throw new Error(`No data handler registered for '${key}'`); return await handler(params) as T; }, - async performAction(key: string, params: Record = {}) { + async performAction( + key: string, + params: Record = {}, + options?: TestHarnessPerformActionOptions, + ) { const handler = actionHandlers.get(key); if (!handler) throw new Error(`No action handler registered for '${key}'`); - return await handler(params) as T; + const context = actionContextFor(params, options); + return await handler(paramsWithHostCompanyScope(params, context, options), context) as T; }, async executeTool(name: string, params: unknown, runCtx: Partial = {}) { const handler = toolHandlers.get(name); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index bcbb7f4e71d..436c5da77f3 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -40,6 +40,7 @@ import type { Agent, Goal, } from "@paperclipai/shared"; +import type { PluginPerformActionContext } from "./protocol.js"; // --------------------------------------------------------------------------- // Re-exports from @paperclipai/shared (plugin authors import from one place) @@ -945,9 +946,12 @@ export interface PluginActionsClient { * Register a handler for a plugin-defined action key. * * @param key - Stable string identifier for this action (e.g. `"resync"`) - * @param handler - Async function that receives action params and returns a result + * @param handler - Async function that receives action params plus immutable host actor context and returns a result */ - register(key: string, handler: (params: Record) => Promise): void; + register( + key: string, + handler: (params: Record, context: PluginPerformActionContext) => Promise, + ): void; } /** diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 9bfae39e675..7b0d1a4d696 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -35,6 +35,7 @@ */ import fs from "node:fs"; +import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { fileURLToPath } from "node:url"; @@ -76,6 +77,8 @@ import type { RunJobParams, GetDataParams, PerformActionParams, + PluginPerformActionActorContext, + PluginPerformActionContext, ExecuteToolParams, PluginEnvironmentAcquireLeaseParams, PluginEnvironmentDestroyLeaseParams, @@ -285,7 +288,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost const jobHandlers = new Map Promise>(); const launcherRegistrations = new Map(); const dataHandlers = new Map) => Promise>(); - const actionHandlers = new Map) => Promise>(); + const actionHandlers = new Map< + string, + (params: Record, context: PluginPerformActionContext) => Promise + >(); const toolHandlers = new Map; fn: (params: unknown, runCtx: ToolRunContext) => Promise; @@ -301,6 +307,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }>(); let nextOutboundId = 1; const MAX_OUTBOUND_ID = Number.MAX_SAFE_INTEGER - 1; + const invocationStorage = new AsyncLocalStorage(); // ----------------------------------------------------------------------- // Outbound messaging (worker → host) @@ -366,7 +373,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost try { const request = createRequest(method, params, id); - sendMessage(request); + const invocationId = invocationStorage.getStore(); + sendMessage(invocationId ? { ...request, paperclipInvocationId: invocationId } : request); } catch (err) { settle(reject, err instanceof Error ? err : new Error(String(err))); } @@ -1093,7 +1101,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, actions: { - register(key: string, handler: (params: Record) => Promise): void { + register( + key: string, + handler: (params: Record, context: PluginPerformActionContext) => Promise, + ): void { actionHandlers.set(key, handler); }, }, @@ -1173,22 +1184,33 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost */ async function handleHostRequest(request: JsonRpcRequest): Promise { const { id, method, params } = request; + const invocationId = typeof request.paperclipInvocation?.id === "string" + ? request.paperclipInvocation.id + : null; - try { - const result = await dispatchMethod(method, params); - sendMessage(createSuccessResponse(id, result ?? null)); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - // Propagate specific error codes from handler errors (e.g. - // METHOD_NOT_FOUND, METHOD_NOT_IMPLEMENTED) — fall back to - // WORKER_ERROR for untyped exceptions. - const errorCode = - typeof (err as any)?.code === "number" - ? (err as any).code - : PLUGIN_RPC_ERROR_CODES.WORKER_ERROR; + const execute = async (): Promise => { + try { + const result = await dispatchMethod(method, params); + sendMessage(createSuccessResponse(id, result ?? null)); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + // Propagate specific error codes from handler errors (e.g. + // METHOD_NOT_FOUND, METHOD_NOT_IMPLEMENTED) — fall back to + // WORKER_ERROR for untyped exceptions. + const errorCode = + typeof (err as any)?.code === "number" + ? (err as any).code + : PLUGIN_RPC_ERROR_CODES.WORKER_ERROR; - sendMessage(createErrorResponse(id, errorCode, errorMessage)); + sendMessage(createErrorResponse(id, errorCode, errorMessage)); + } + }; + + if (invocationId) { + await invocationStorage.run(invocationId, execute); + return; } + await execute(); } /** @@ -1420,6 +1442,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost ); } + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { + return value === "user" || value === "agent" || value === "system" ? value : "system"; + } + + function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext { + const rawActor = params.actorContext && typeof params.actorContext === "object" + ? params.actorContext + : null; + const legacyCompanyId = stringOrNull(params.params.companyId); + const actor = Object.freeze({ + type: actorTypeOrSystem(rawActor?.type), + userId: stringOrNull(rawActor?.userId), + agentId: stringOrNull(rawActor?.agentId), + runId: stringOrNull(rawActor?.runId), + companyId: stringOrNull(rawActor?.companyId) ?? legacyCompanyId, + }); + return Object.freeze({ + actor, + companyId: actor.companyId, + }); + } + async function handlePerformAction(params: PerformActionParams): Promise { const handler = actionHandlers.get(params.key); if (!handler) { @@ -1429,6 +1477,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost params.renderEnvironment === undefined ? params.params : { ...params.params, renderEnvironment: params.renderEnvironment }, + actionContextFromParams(params), ); } diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts new file mode 100644 index 00000000000..1c7d3da0e70 --- /dev/null +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { HostServices } from "../src/host-client-factory.js"; +import { + createHostClientHandlers, + InvocationScopeDeniedError, +} from "../src/host-client-factory.js"; + +describe("createHostClientHandlers invocation company scope", () => { + it("rejects company-scoped host calls outside the current invocation company", async () => { + const projectsList = vi.fn(async () => []); + const services = { + projects: { + list: projectsList, + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["projects.read"], + services, + }); + + await expect( + handlers["projects.list"]( + { companyId: "company-b" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + expect(projectsList).not.toHaveBeenCalled(); + }); + + it("filters companies.list to the current invocation company", async () => { + const services = { + companies: { + list: vi.fn(async () => [ + { id: "company-a", name: "Company A" }, + { id: "company-b", name: "Company B" }, + ]), + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["companies.read"], + services, + }); + + await expect( + handlers["companies.list"]( + {}, + { invocationScope: { companyId: "company-a" } }, + ), + ).resolves.toEqual([{ id: "company-a", name: "Company A" }]); + }); + + it("rejects company-scope store access for a different company", async () => { + const stateGet = vi.fn(async () => null); + const services = { + state: { + get: stateGet, + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["plugin.state.read"], + services, + }); + + await expect( + handlers["state.get"]( + { scopeKind: "company", scopeId: "company-b", stateKey: "settings" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + expect(stateGet).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/sdk/tests/testing-actions.test.ts b/packages/plugins/sdk/tests/testing-actions.test.ts new file mode 100644 index 00000000000..ab0026b283f --- /dev/null +++ b/packages/plugins/sdk/tests/testing-actions.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { createTestHarness } from "../src/testing.js"; +import type { PaperclipPluginManifestV1 } from "../src/types.js"; + +const manifest = { + id: "paperclip.test-actions", + apiVersion: 1, + version: "1.0.0", + displayName: "Test Actions", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, +} satisfies PaperclipPluginManifestV1; + +describe("createTestHarness action context", () => { + it("passes immutable authenticated actor context and overrides caller company scope", async () => { + const harness = createTestHarness({ manifest }); + + harness.ctx.actions.register("inspect", async (params, context) => ({ + paramsCompanyId: params.companyId, + actor: context.actor, + companyId: context.companyId, + contextFrozen: Object.isFrozen(context), + actorFrozen: Object.isFrozen(context.actor), + })); + + const result = await harness.performAction<{ + paramsCompanyId: unknown; + actor: { + type: string; + userId: string | null; + agentId: string | null; + runId: string | null; + companyId: string | null; + }; + companyId: string | null; + contextFrozen: boolean; + actorFrozen: boolean; + }>( + "inspect", + { companyId: "spoofed-company", value: true }, + { + companyId: "host-company", + actor: { + type: "user", + userId: "board-user-1", + runId: "run-1", + }, + }, + ); + + expect(result.paramsCompanyId).toBe("host-company"); + expect(result.companyId).toBe("host-company"); + expect(result.actor).toEqual({ + type: "user", + userId: "board-user-1", + agentId: null, + runId: "run-1", + companyId: "host-company", + }); + expect(result.contextFrozen).toBe(true); + expect(result.actorFrozen).toBe(true); + }); + + it("keeps existing one-argument action handlers compatible", async () => { + const harness = createTestHarness({ manifest }); + harness.ctx.actions.register("legacy", async (params) => ({ ok: params.ok })); + + await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true }); + }); +}); diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs new file mode 100644 index 00000000000..9e1f06dfb36 --- /dev/null +++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs @@ -0,0 +1,100 @@ +const readline = require("node:readline"); + +let nextRequestId = 1; +const pendingNested = new Map(); + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function sendNestedHostRequest(originalRequest, invocationId) { + const nestedId = `nested-${nextRequestId++}`; + const params = originalRequest.params?.params ?? {}; + const mode = params.mode; + const requestedCompanyId = params.requestedCompanyId; + const nestedRequest = { + jsonrpc: "2.0", + id: nestedId, + method: "companies.get", + params: { + companyId: requestedCompanyId, + }, + }; + + if (mode === "echo") { + nestedRequest.paperclipInvocationId = invocationId; + } else if (mode === "unknown") { + nestedRequest.paperclipInvocationId = "unknown-invocation"; + } + + pendingNested.set(nestedId, originalRequest.id); + send(nestedRequest); +} + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +rl.on("line", (line) => { + if (!line.trim()) return; + const message = JSON.parse(line); + + if (message.id && pendingNested.has(message.id)) { + const originalId = pendingNested.get(message.id); + pendingNested.delete(message.id); + if (message.error) { + send({ + jsonrpc: "2.0", + id: originalId, + error: message.error, + }); + return; + } + + send({ + jsonrpc: "2.0", + id: originalId, + result: message.result, + }); + return; + } + + const method = message && typeof message.method === "string" ? message.method : null; + + if (method === "initialize") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + ok: true, + supportedMethods: ["getData", "performAction"], + }, + }); + return; + } + + if (method === "getData" || method === "performAction") { + sendNestedHostRequest(message, message.paperclipInvocation?.id); + return; + } + + if (method === "shutdown") { + send({ + jsonrpc: "2.0", + id: message.id, + result: {}, + }); + setImmediate(() => process.exit(0)); + return; + } + + send({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: `Unhandled method: ${method}`, + }, + }); +}); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 0c19848822f..0b8a14da391 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -114,6 +114,17 @@ function boardActor(overrides: Record = {}) { }; } +function agentActor(overrides: Record = {}) { + return { + type: "agent", + agentId: agentA, + companyId: companyA, + runId: runA, + source: "agent_jwt", + ...overrides, + }; +} + function readyPlugin() { mockRegistry.getById.mockResolvedValue({ id: pluginId, @@ -634,10 +645,137 @@ describe.sequential("plugin tool and bridge authz", () => { expect(call).toHaveBeenCalledWith(pluginId, "performAction", { key: "sync", params: {}, + actorContext: { + type: "user", + userId: "admin-1", + agentId: null, + runId: null, + companyId: null, + }, renderEnvironment: null, }); }); + it("passes authenticated actor context and overrides spoofed company scope for plugin actions", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor({ runId: runA }), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ + companyId: companyA, + params: { + companyId: companyB, + reviewerUserId: "spoofed-user", + }, + }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerUserId: "spoofed-user", + }, + actorContext: { + type: "user", + userId: "user-1", + agentId: null, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + }); + + it("allows agent-scoped plugin actions with authenticated actor context", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(agentActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ + companyId: companyA, + params: { + companyId: companyB, + reviewerAgentId: "spoofed-agent", + }, + }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerAgentId: "spoofed-agent", + }, + actorContext: { + type: "agent", + userId: null, + agentId: agentA, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + + call.mockClear(); + const legacyRes = await request(app) + .post(`/api/plugins/${pluginId}/bridge/action`) + .send({ + key: "sync", + companyId: companyA, + params: { + companyId: companyB, + reviewerAgentId: "spoofed-agent", + }, + }); + + expect(legacyRes.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerAgentId: "spoofed-agent", + }, + actorContext: { + type: "agent", + userId: null, + agentId: agentA, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + }); + + it("rejects agent plugin actions outside the authenticated company scope", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(agentActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ companyId: companyB }); + + expect(res.status).toBe(403); + expect(call).not.toHaveBeenCalled(); + }); + it("attaches worker bridge errors to the HTTP logger context", async () => { readyPlugin(); const call = vi.fn().mockRejectedValue(new Error("missing source_objects column")); diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index 4f578fda072..adb61c43315 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -3,6 +3,8 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { + createHostClientHandlers, + type HostServices, JsonRpcCallError, type HostToWorkerMethods, } from "@paperclipai/plugin-sdk"; @@ -15,6 +17,7 @@ import { const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs"); const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs"); +const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-invocation-scope.cjs"); const TEST_MANIFEST: PaperclipPluginManifestV1 = { id: "test.plugin", @@ -178,4 +181,145 @@ describe("plugin-worker-manager stderr failure context", () => { await handle.stop().catch(() => undefined); } }); + + it("passes performAction invocation scope to nested worker host calls", async () => { + const companiesGet = vi.fn(async ( + params: { companyId: string }, + context?: { invocationScope?: { companyId?: string | null } | null }, + ) => ({ + id: params.companyId, + scopedCompanyId: context?.invocationScope?.companyId ?? null, + })); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: { + "companies.get": companiesGet as never, + }, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + mode: "echo", + requestedCompanyId: "company-a", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).resolves.toEqual({ + id: "company-a", + scopedCompanyId: "company-a", + }); + expect(companiesGet).toHaveBeenCalledWith( + { companyId: "company-a" }, + { invocationScope: { companyId: "company-a" } }, + ); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("applies the active performAction scope when a nested worker host call omits the invocation id", async () => { + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: ["companies.read"], + services: { + companies: { + list: vi.fn(async () => []), + get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })), + }, + } as unknown as HostServices, + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: handlers, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + requestedCompanyId: "company-b", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).rejects.toMatchObject({ + message: expect.stringContaining("scoped to company \"company-a\""), + }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("rejects nested worker host calls that forge an unknown invocation id", async () => { + const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: { + "companies.get": companiesGet as never, + }, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + mode: "unknown", + requestedCompanyId: "company-a", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).rejects.toMatchObject({ + message: expect.stringContaining("unknown or expired invocation scope"), + }); + expect(companiesGet).not.toHaveBeenCalled(); + } finally { + await handle.stop().catch(() => undefined); + } + }); }); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 6abb7c56ec5..3914827335d 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -55,7 +55,7 @@ import type { PluginJobStore } from "../services/plugin-job-store.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; -import type { ToolRunContext } from "@paperclipai/plugin-sdk"; +import type { PluginPerformActionActorContext, ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; import { assertAuthenticated, @@ -572,6 +572,43 @@ export function pluginRoutes( return companyId; } + function performActionActorContext(req: Request, companyId: string | undefined): PluginPerformActionActorContext { + const scopedCompanyId = companyId ?? null; + if (req.actor.type === "agent") { + return { + type: "agent", + userId: null, + agentId: req.actor.agentId ?? null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + if (req.actor.type === "board") { + return { + type: "user", + userId: req.actor.userId ?? "board", + agentId: null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + return { + type: "system", + userId: null, + agentId: null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + + function actionParamsWithAuthorizedCompanyScope( + params: Record | undefined, + companyId: string | undefined, + ): Record { + const base = params ?? {}; + return companyId === undefined ? base : { ...base, companyId }; + } + async function validateToolRunContextScope(runContext: ToolRunContext): Promise { const [agent] = await db .select({ companyId: agents.companyId }) @@ -1170,7 +1207,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { - assertBoardOrgAccess(req); + assertAuthenticated(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1208,7 +1245,7 @@ export function pluginRoutes( return; } - assertPluginBridgeScope(req, body.companyId); + const companyId = assertPluginBridgeScope(req, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1216,7 +1253,8 @@ export function pluginRoutes( "performAction", { key: body.key, - params: body.params ?? {}, + params: actionParamsWithAuthorizedCompanyScope(body.params, companyId), + actorContext: performActionActorContext(req, companyId), renderEnvironment: body.renderEnvironment ?? null, }, ); @@ -1352,7 +1390,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { - assertBoardOrgAccess(req); + assertAuthenticated(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1390,7 +1428,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - assertPluginBridgeScope(req, body?.companyId); + const companyId = assertPluginBridgeScope(req, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1398,7 +1436,8 @@ export function pluginRoutes( "performAction", { key, - params: body?.params ?? {}, + params: actionParamsWithAuthorizedCompanyScope(body?.params, companyId), + actorContext: performActionActorContext(req, companyId), renderEnvironment: body?.renderEnvironment ?? null, }, ); diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 6e1d7689be2..a67dc80d96f 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -19,6 +19,7 @@ */ import { fork, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; @@ -102,12 +103,21 @@ export type WorkerStatus = | "crashed" | "backoff"; +export interface WorkerInvocationScope { + companyId?: string | null; +} + +export interface WorkerToHostHandlerContext { + invocationScope?: WorkerInvocationScope | null; +} + /** * Worker-to-host method handler. The host registers these to service calls * that the plugin worker makes back to the host (e.g. state.get, events.emit). */ export type WorkerToHostHandler = ( params: WorkerToHostMethods[M][0], + context?: WorkerToHostHandlerContext, ) => Promise; /** @@ -199,6 +209,8 @@ interface PendingRequest { timer: ReturnType; /** Timestamp when the request was sent. */ sentAt: number; + /** Active host-owned invocation id attached to this host→worker call. */ + invocationId?: string; } // --------------------------------------------------------------------------- @@ -379,6 +391,7 @@ export function createPluginWorkerHandle( // Pending RPC requests awaiting a response const pendingRequests = new Map(); let nextRequestId = 1; + const activeInvocationScopes = new Map(); // Optional methods reported by the worker during initialization let supportedMethods: string[] = []; @@ -424,6 +437,38 @@ export function createPluginWorkerHandle( childProcess.stdin.write(serialized); } + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function invocationScopeForCall( + method: HostToWorkerMethodName, + params: unknown, + ): WorkerInvocationScope | null { + if (method !== "performAction" || !params || typeof params !== "object") return null; + const actorContext = (params as { actorContext?: { companyId?: unknown } | null }).actorContext; + const companyId = stringOrNull(actorContext?.companyId); + return companyId ? { companyId } : null; + } + + function invocationContextForWorkerRequest(request: JsonRpcRequest): WorkerToHostHandlerContext | undefined { + const invocationId = stringOrNull(request.paperclipInvocationId); + if (invocationId) { + const invocationScope = activeInvocationScopes.get(invocationId); + if (!invocationScope) { + throw new Error("Worker host request referenced an unknown or expired invocation scope"); + } + return { invocationScope }; + } + if (activeInvocationScopes.size === 1) { + return { invocationScope: [...activeInvocationScopes.values()][0] }; + } + if (activeInvocationScopes.size > 1) { + throw new Error("Worker host request omitted invocation scope while multiple scoped invocations are active"); + } + return undefined; + } + // ----------------------------------------------------------------------- // Incoming message handling // ----------------------------------------------------------------------- @@ -481,7 +526,7 @@ export function createPluginWorkerHandle( async function handleWorkerRequest(request: JsonRpcRequest): Promise { const method = request.method as WorkerToHostMethodName; const handler = options.hostHandlers[method] as - | ((params: unknown) => Promise) + | ((params: unknown, context?: WorkerToHostHandlerContext) => Promise) | undefined; if (!handler) { @@ -501,7 +546,7 @@ export function createPluginWorkerHandle( } try { - const result = await handler(request.params); + const result = await handler(request.params, invocationContextForWorkerRequest(request)); sendMessage({ jsonrpc: JSONRPC_VERSION, id: request.id, @@ -751,6 +796,7 @@ export function createPluginWorkerHandle( function rejectAllPending(error: Error): void { for (const [id, pending] of pendingRequests) { clearTimeout(pending.timer); + if (pending.invocationId) activeInvocationScopes.delete(pending.invocationId); pending.resolve( createErrorResponse( pending.id, @@ -760,6 +806,7 @@ export function createPluginWorkerHandle( ); } pendingRequests.clear(); + activeInvocationScopes.clear(); } // ----------------------------------------------------------------------- @@ -1020,6 +1067,9 @@ export function createPluginWorkerHandle( const id = nextRequestId++; const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS); + const invocationScope = invocationScopeForCall(method, params); + const invocation = invocationScope ? { id: randomUUID(), scope: invocationScope } : null; + if (invocation) activeInvocationScopes.set(invocation.id, invocation.scope); // Guard against double-settlement. When a process exits all pending // requests are rejected via rejectAllPending(), but the timeout timer @@ -1032,6 +1082,7 @@ export function createPluginWorkerHandle( settled = true; clearTimeout(timer); pendingRequests.delete(id); + if (invocation) activeInvocationScopes.delete(invocation.id); fn(value); }; @@ -1059,16 +1110,18 @@ export function createPluginWorkerHandle( }, timer, sentAt: Date.now(), + invocationId: invocation?.id, }; pendingRequests.set(id, pending); try { const request = createRequest(method, params, id); - sendMessage(request); + sendMessage(invocation ? { ...request, paperclipInvocation: invocation } : request); } catch (err) { clearTimeout(timer); pendingRequests.delete(id); + if (invocation) activeInvocationScopes.delete(invocation.id); reject( new Error( `Failed to send "${method}" to worker: ${