[codex] Harden plugin runtime invocation scope (#6547)

## Thinking Path

> - Paperclip orchestrates AI-agent companies through a company-scoped
control plane.
> - Plugins extend that control plane, but plugin workers still call
back into host APIs.
> - Those worker-to-host calls need the same company boundary guarantees
as normal API routes.
> - Plugin action handlers also need authenticated actor context from
the host instead of trusting caller-supplied params.
> - This pull request hardens plugin bridge/action scope and keeps
plugin operation issues out of normal issue surfaces.
> - The benefit is safer plugin execution with clearer authorization
boundaries and better test coverage.

## What Changed

- Added host-owned invocation context plumbing for nested plugin worker
calls.
- Added actor context to plugin `performAction` calls and test harness
helpers.
- Enforced company invocation scope on worker-to-host calls and filtered
company lists to the active invocation scope.
- Extended plugin action route tests for board and agent actor context,
spoofed company params, and cross-company rejection.
- Extended plugin worker manager coverage for invocation-scope
propagation.
- Filtered typed and legacy plugin operation issue origins from default
issue/inbox lists.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run
packages/plugins/sdk/tests/host-client-factory.test.ts
packages/plugins/sdk/tests/testing-actions.test.ts
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/plugin-worker-manager.test.ts
server/src/__tests__/issues-service.test.ts`

Note: embedded Postgres issue-service tests reported host-level Postgres
init skip for 47 tests; the non-embedded targeted tests passed.

## Risks

- Medium: plugin host authorization paths are sensitive, and external
plugins may rely on previously loose company params.
- Mitigation: the change only tightens calls when the host attached a
company invocation scope and includes explicit tests for board, agent,
and nested worker calls.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.

## 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
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-05-22 09:16:24 -05:00 committed by GitHub
parent 38c185fb8b
commit a1835cfa5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 749 additions and 30 deletions

View file

@ -79,7 +79,7 @@ export class CapabilityDeniedError extends Error {
*/
export class InvocationScopeDeniedError extends Error {
override readonly name = "InvocationScopeDeniedError";
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
readonly code = PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED;
constructor(pluginId: string, method: string, message: string) {
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);

View file

@ -149,6 +149,9 @@ export type {
RunJobParams,
GetDataParams,
PerformActionParams,
PluginPerformActionActorType,
PluginPerformActionActorContext,
PluginPerformActionContext,
ExecuteToolParams,
PluginEnvironmentDiagnostic,
PluginEnvironmentDriverBaseParams,

View file

@ -233,6 +233,8 @@ export const PLUGIN_RPC_ERROR_CODES = {
TIMEOUT: -32003,
/** The worker does not implement the requested optional method. */
METHOD_NOT_IMPLEMENTED: -32004,
/** The worker→host call attempted to escape the current invocation company scope. */
INVOCATION_SCOPE_DENIED: -32005,
/** A catch-all for errors that do not fit other categories. */
UNKNOWN: -32099,
} as const;
@ -368,6 +370,28 @@ 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<PluginPerformActionActorContext>;
/** Convenience alias for `actor.companyId`. */
companyId: string | null;
}
export interface PerformActionParams {
/** Plugin-defined action key (e.g. `"resync"`). */
key: string;
@ -375,6 +399,8 @@ export interface PerformActionParams {
companyId?: string | null;
/** Action parameters from the UI. */
params: Record<string, unknown>;
/** 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;
}

View file

@ -57,6 +57,8 @@ import type {
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginPerformActionActorContext,
PluginPerformActionContext,
} from "./protocol.js";
export interface TestHarnessOptions {
@ -74,6 +76,20 @@ export interface TestHarnessLogEntry {
meta?: Record<string, unknown>;
}
export interface TestHarnessPerformActionOptions {
/**
* Authenticated actor context to expose to the action handler. Omitted fields
* default to null, and `type` defaults to `system`.
*/
actor?: Partial<PluginPerformActionActorContext> | 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;
@ -98,7 +114,11 @@ export interface TestHarness {
/** Invoke a `ctx.data.register(...)` handler by key. */
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
/** Invoke a `ctx.actions.register(...)` handler by key. */
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
performAction<T = unknown>(
key: string,
params?: Record<string, unknown>,
options?: TestHarnessPerformActionOptions,
): Promise<T>;
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
/** Read raw in-memory state for assertions. */
@ -491,7 +511,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
const launchers = new Map<string, PluginLauncherRegistration>();
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const actionHandlers = new Map<
string,
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
>();
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
function localFolderKey(companyId: string, folderKey: string): string {
@ -502,6 +525,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<string, unknown>,
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<string, unknown>,
context: PluginPerformActionContext,
options?: TestHarnessPerformActionOptions,
): Record<string, unknown> {
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(/[\\/]+/)) {
@ -2302,10 +2360,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<T = unknown>(key: string, params: Record<string, unknown> = {}) {
async performAction<T = unknown>(
key: string,
params: Record<string, unknown> = {},
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<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
const handler = toolHandlers.get(name);

View file

@ -46,6 +46,7 @@ import type {
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
import type { PluginPerformActionContext } from "./protocol.js";
// ---------------------------------------------------------------------------
// Re-exports from @paperclipai/shared (plugin authors import from one place)
@ -957,9 +958,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<string, unknown>) => Promise<unknown>): void;
register(
key: string,
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
): void;
}
/**

View file

@ -54,6 +54,7 @@ export type {
* Error codes:
* - `WORKER_UNAVAILABLE` plugin worker is not running
* - `CAPABILITY_DENIED` plugin lacks the required capability
* - `INVOCATION_SCOPE_DENIED` plugin call escaped the invocation company scope
* - `WORKER_ERROR` worker returned an error from its handler
* - `TIMEOUT` worker did not respond within the configured timeout
* - `UNKNOWN` unexpected bridge-level failure

View file

@ -78,6 +78,8 @@ import type {
RunJobParams,
GetDataParams,
PerformActionParams,
PluginPerformActionActorContext,
PluginPerformActionContext,
ExecuteToolParams,
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
@ -289,7 +291,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const jobHandlers = new Map<string, (job: PluginJobContext) => Promise<void>>();
const launcherRegistrations = new Map<string, PluginLauncherRegistration>();
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
const actionHandlers = new Map<
string,
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
>();
const toolHandlers = new Map<string, {
declaration: Pick<import("@paperclipai/shared").PluginToolDeclaration, "displayName" | "description" | "parametersSchema">;
fn: (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>;
@ -1184,7 +1189,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
actions: {
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
register(
key: string,
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
): void {
actionHandlers.set(key, handler);
},
},
@ -1514,16 +1522,44 @@ 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 actor = Object.freeze({
type: actorTypeOrSystem(rawActor?.type),
userId: stringOrNull(rawActor?.userId),
agentId: stringOrNull(rawActor?.agentId),
runId: stringOrNull(rawActor?.runId),
companyId: stringOrNull(rawActor?.companyId),
});
return Object.freeze({
actor,
companyId: actor.companyId,
});
}
async function handlePerformAction(params: PerformActionParams): Promise<unknown> {
const handler = actionHandlers.get(params.key);
if (!handler) {
throw new Error(`No action handler registered for key "${params.key}"`);
}
return handler({
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
return handler(
{
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
},
actionContextFromParams(params),
);
}
async function handleExecuteTool(params: ExecuteToolParams): Promise<ToolResult> {

View file

@ -6,6 +6,7 @@ import {
createHostClientHandlers,
InvocationScopeDeniedError,
} from "../src/host-client-factory.js";
import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
describe("createHostClientHandlers invocation company scope", () => {
it("rejects company-scoped host calls outside the current invocation company", async () => {
@ -28,6 +29,14 @@ describe("createHostClientHandlers invocation company scope", () => {
{ invocationScope: { companyId: "company-a" } },
),
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
await expect(
handlers["projects.list"](
{ companyId: "company-b" },
{ invocationScope: { companyId: "company-a" } },
),
).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
});
expect(projectsList).not.toHaveBeenCalled();
});

View file

@ -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 });
});
});

View file

@ -71,6 +71,90 @@ describe("isWorkerEntrypoint", () => {
});
});
describe("worker performAction context", () => {
it("does not derive context companyId from caller params without host actor context", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
let nextRequestId = 1;
const plugin = definePlugin({
async setup(ctx) {
ctx.actions.register("inspect", async (params, context) => ({
paramsCompanyId: params.companyId,
actor: context.actor,
companyId: context.companyId,
}));
},
});
const worker = startWorkerRpcHost({
plugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown) {
const id = `host-${nextRequestId++}`;
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (!isJsonRpcResponse(message)) return;
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
});
try {
await expect(callWorker("initialize", {
manifest: {
id: "paperclip.test-worker-context",
apiVersion: 1,
version: "1.0.0",
displayName: "Worker Context Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: {},
},
config: {},
databaseNamespace: null,
})).resolves.toMatchObject({ ok: true });
await expect(callWorker("performAction", {
key: "inspect",
params: { companyId: "spoofed-company" },
})).resolves.toEqual({
paramsCompanyId: "spoofed-company",
actor: {
type: "system",
userId: null,
agentId: null,
runId: null,
companyId: null,
},
companyId: null,
});
} finally {
worker.stop();
hostReadline.close();
hostToWorker.destroy();
workerToHost.destroy();
}
});
});
describe("worker invocation scope propagation", () => {
it("keeps overlapping company scopes local to each getData invocation", async () => {
const hostToWorker = new PassThrough();