[codex] Add agent permissions and controls plan (#6386)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies by keeping
task ownership, approvals, and operator control inside one control
plane.
> - Agent permissions and plugin-hosted company settings sit on the
boundary between autonomy and governance.
> - V1 needs scoped task assignment rules, plugin extension points, and
clearer company access surfaces without weakening company boundaries.
> - The branch builds the core authorization service, plugin SDK/host
APIs, and UI simplifications needed to support those controls.
> - Paperclip EE plugin surfaces were intentionally moved out of this
core PR per review direction, so this PR now carries only the public
core/plugin infrastructure work.
> - The latest updates preserve the PAP-9937 branch changes that belong
in this PR, remove the `design/` artifacts, and exclude the experimental
`plugin-briefs` package.
> - Greptile feedback was applied through the authorization/audit paths
and the final cleanup commit was re-reviewed at 5/5 with no unresolved
Greptile threads.
> - The benefit is safer assignment control with extension hooks for
richer permission products while preserving simple defaults for normal
operators.

## What Changed

- Added scoped task-assignment authorization decisions and routed
issue/agent assignment mutations through the authorization service.
- Added plugin SDK and host APIs for company settings slots,
authorization policy/grant management, assignment previews, and bridge
invocation scope propagation.
- Simplified core company access UI and moved advanced controls behind
plugin-provided settings surfaces.
- Added retry-now affordances for blocked issue next-step notices.
- Added protected-assignment enforcement for persisted
agent/project/issue policies, including explicit-grant fallback
behavior.
- Added incremental principal-access compatibility backfill for active
agent memberships and role-default human permission grants.
- Added the Markdown code block wrap action fix from the latest branch
changes.
- Removed `design/` artifacts from the PR and removed
`packages/plugins/plugin-briefs` from the final diff.
- Addressed Greptile feedback for plugin actor sanitization, legacy
membership handling, audit pagination, unknown grant-scope metadata, and
startup test mocks.

## Verification

- `pnpm exec vitest run server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54
tests passed.
- `pnpm exec vitest run
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62
tests passed.
- `pnpm exec vitest run
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/plugin-access-authorization-host-services.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files
passed, 28 tests passed.
- `pnpm --filter @paperclipai/server typecheck` -> passed.
- `git diff --check` -> passed.
- `node ./scripts/check-docker-deps-stage.mjs` -> passed.
- `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed
with no lockfile update.
- `pnpm exec vitest run
ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed.
- `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0.
- GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`.
- Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0
comments/annotations added, 0 unresolved review threads.
- Confirmed the PR diff contains no `design/`,
`packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or
`.github/workflows` changes.

## Risks

- Medium: task assignment authorization paths are behaviorally stricter
for protected/private policy data, so existing plugin-authored policies
may block assignment until explicit grants or approval flows are
configured.
- Medium: plugin-host authorization APIs expand the surface area
available to trusted plugins and need careful review for company
scoping.
- Low: startup now performs a principal-access compatibility backfill,
but the migration and runtime backfill use conflict-tolerant inserts.

> 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 Codex, GPT-5 coding agent, tool-enabled workflow with shell,
git, and GitHub CLI access.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-22 08:12:52 -05:00 committed by GitHub
parent c91a062326
commit 38c185fb8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 6744 additions and 395 deletions

View file

@ -49,7 +49,7 @@
*/
import type { PluginCapability } from "@paperclipai/shared";
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
// ---------------------------------------------------------------------------
@ -73,6 +73,19 @@ export class CapabilityDeniedError extends Error {
}
}
/**
* Thrown when a workerhost call asks for company-scoped data outside the
* company authorized for the current top-level plugin invocation.
*/
export class InvocationScopeDeniedError extends Error {
override readonly name = "InvocationScopeDeniedError";
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
constructor(pluginId: string, method: string, message: string) {
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
}
}
// ---------------------------------------------------------------------------
// Host service interfaces
// ---------------------------------------------------------------------------
@ -257,6 +270,28 @@ export interface HostServices {
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
};
/** Provides `access.members.*` and `access.invites.*`. */
access: {
listMembers(params: WorkerToHostMethods["access.members.list"][0]): Promise<WorkerToHostMethods["access.members.list"][1]>;
getMember(params: WorkerToHostMethods["access.members.get"][0]): Promise<WorkerToHostMethods["access.members.get"][1]>;
updateMember(params: WorkerToHostMethods["access.members.update"][0]): Promise<WorkerToHostMethods["access.members.update"][1]>;
listInvites(params: WorkerToHostMethods["access.invites.list"][0]): Promise<WorkerToHostMethods["access.invites.list"][1]>;
createInvite(params: WorkerToHostMethods["access.invites.create"][0]): Promise<WorkerToHostMethods["access.invites.create"][1]>;
revokeInvite(params: WorkerToHostMethods["access.invites.revoke"][0]): Promise<WorkerToHostMethods["access.invites.revoke"][1]>;
};
/** Provides authorization grant, policy, preview, and audit helpers. */
authorization: {
listGrants(params: WorkerToHostMethods["authorization.grants.list"][0]): Promise<WorkerToHostMethods["authorization.grants.list"][1]>;
setGrants(params: WorkerToHostMethods["authorization.grants.set"][0]): Promise<WorkerToHostMethods["authorization.grants.set"][1]>;
policySummary(params: WorkerToHostMethods["authorization.policies.summary"][0]): Promise<WorkerToHostMethods["authorization.policies.summary"][1]>;
getPolicy(params: WorkerToHostMethods["authorization.policies.get"][0]): Promise<WorkerToHostMethods["authorization.policies.get"][1]>;
updatePolicy(params: WorkerToHostMethods["authorization.policies.update"][0]): Promise<WorkerToHostMethods["authorization.policies.update"][1]>;
previewAssignment(params: WorkerToHostMethods["authorization.policies.previewAssignment"][0]): Promise<WorkerToHostMethods["authorization.policies.previewAssignment"][1]>;
explainAssignment(params: WorkerToHostMethods["authorization.policies.explainAssignment"][0]): Promise<WorkerToHostMethods["authorization.policies.explainAssignment"][1]>;
searchAudit(params: WorkerToHostMethods["authorization.audit.search"][0]): Promise<WorkerToHostMethods["authorization.audit.search"][1]>;
};
}
// ---------------------------------------------------------------------------
@ -292,6 +327,7 @@ export interface HostClientFactoryOptions {
*/
type HostHandler<M extends WorkerToHostMethodName> = (
params: WorkerToHostMethods[M][0],
context?: WorkerHostCallContext,
) => Promise<WorkerToHostMethods[M][1]>;
/**
@ -431,6 +467,24 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"goals.get": "goals.read",
"goals.create": "goals.create",
"goals.update": "goals.update",
// Access
"access.members.list": "access.members.read",
"access.members.get": "access.members.read",
"access.members.update": "access.members.write",
"access.invites.list": "access.invites.read",
"access.invites.create": "access.invites.write",
"access.invites.revoke": "access.invites.write",
// Authorization
"authorization.grants.list": "authorization.grants.read",
"authorization.grants.set": "authorization.grants.write",
"authorization.policies.summary": "authorization.policies.read",
"authorization.policies.get": "authorization.policies.read",
"authorization.policies.update": "authorization.policies.write",
"authorization.policies.previewAssignment": "authorization.policies.read",
"authorization.policies.explainAssignment": "authorization.policies.read",
"authorization.audit.search": "authorization.audit.read",
};
// ---------------------------------------------------------------------------
@ -461,6 +515,81 @@ export function createHostClientHandlers(
const { pluginId, services } = options;
const capabilitySet = new Set<PluginCapability>(options.capabilities);
type CompanyScopeRequest =
| { kind: "none" }
| { kind: "single"; companyId: string }
| { kind: "all" };
const noCompanyScope: CompanyScopeRequest = { kind: "none" };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function requestedCompanyScope(
method: WorkerToHostMethodName,
params: unknown,
): CompanyScopeRequest {
if (method === "companies.list") return { kind: "all" };
if (!isRecord(params)) return noCompanyScope;
const companyId = readNonEmptyString(params.companyId);
if (companyId) return { kind: "single", companyId };
if (params.scopeKind === "company") {
const scopeId = readNonEmptyString(params.scopeId);
return scopeId ? { kind: "single", companyId: scopeId } : { kind: "all" };
}
if (method === "events.subscribe" && isRecord(params.filter)) {
const filterCompanyId = readNonEmptyString(params.filter.companyId);
if (filterCompanyId) return { kind: "single", companyId: filterCompanyId };
}
return noCompanyScope;
}
function requireInvocationCompanyScope(
method: WorkerToHostMethodName,
params: unknown,
context?: WorkerHostCallContext,
): void {
const requested = requestedCompanyScope(method, params);
if (requested.kind === "none") return;
if (context?.invalidInvocationScope) {
throw new InvocationScopeDeniedError(
pluginId,
method,
"the worker referenced a missing, expired, or unknown invocation scope",
);
}
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
if (!allowedCompanyId) return;
if (requested.kind === "all") {
if (method === "companies.list") return;
throw new InvocationScopeDeniedError(
pluginId,
method,
`the current invocation is scoped to company "${allowedCompanyId}"`,
);
}
if (requested.companyId !== allowedCompanyId) {
throw new InvocationScopeDeniedError(
pluginId,
method,
`requested company "${requested.companyId}" but the current invocation is scoped to company "${allowedCompanyId}"`,
);
}
}
/**
* Assert that the plugin has the required capability for a method.
* Throws `CapabilityDeniedError` if the capability is missing.
@ -485,9 +614,10 @@ export function createHostClientHandlers(
method: M,
handler: HostHandler<M>,
): HostHandler<M> {
return async (params: WorkerToHostMethods[M][0]) => {
return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => {
requireCapability(method);
return handler(params);
requireInvocationCompanyScope(method, params, context);
return handler(params, context);
};
}
@ -591,8 +721,13 @@ export function createHostClientHandlers(
}),
// Companies
"companies.list": gated("companies.list", async (params) => {
return services.companies.list(params);
"companies.list": gated("companies.list", async (params, context) => {
const rows = await services.companies.list(params);
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
if (!allowedCompanyId) return rows;
return rows.filter((company) =>
isRecord(company) && company.id === allowedCompanyId,
) as WorkerToHostMethods["companies.list"][1];
}),
"companies.get": gated("companies.get", async (params) => {
return services.companies.get(params);
@ -772,6 +907,52 @@ export function createHostClientHandlers(
"goals.update": gated("goals.update", async (params) => {
return services.goals.update(params);
}),
// Access
"access.members.list": gated("access.members.list", async (params) => {
return services.access.listMembers(params);
}),
"access.members.get": gated("access.members.get", async (params) => {
return services.access.getMember(params);
}),
"access.members.update": gated("access.members.update", async (params) => {
return services.access.updateMember(params);
}),
"access.invites.list": gated("access.invites.list", async (params) => {
return services.access.listInvites(params);
}),
"access.invites.create": gated("access.invites.create", async (params) => {
return services.access.createInvite(params);
}),
"access.invites.revoke": gated("access.invites.revoke", async (params) => {
return services.access.revokeInvite(params);
}),
// Authorization
"authorization.grants.list": gated("authorization.grants.list", async (params) => {
return services.authorization.listGrants(params);
}),
"authorization.grants.set": gated("authorization.grants.set", async (params) => {
return services.authorization.setGrants(params);
}),
"authorization.policies.summary": gated("authorization.policies.summary", async (params) => {
return services.authorization.policySummary(params);
}),
"authorization.policies.get": gated("authorization.policies.get", async (params) => {
return services.authorization.getPolicy(params);
}),
"authorization.policies.update": gated("authorization.policies.update", async (params) => {
return services.authorization.updatePolicy(params);
}),
"authorization.policies.previewAssignment": gated("authorization.policies.previewAssignment", async (params) => {
return services.authorization.previewAssignment(params);
}),
"authorization.policies.explainAssignment": gated("authorization.policies.explainAssignment", async (params) => {
return services.authorization.explainAssignment(params);
}),
"authorization.audit.search": gated("authorization.audit.search", async (params) => {
return services.authorization.searchAudit(params);
}),
};
}

View file

@ -58,6 +58,7 @@ export {
createHostClientHandlers,
getRequiredCapability,
CapabilityDeniedError,
InvocationScopeDeniedError,
} from "./host-client-factory.js";
// JSON-RPC protocol helpers and constants
@ -137,6 +138,9 @@ export type {
JsonRpcMessage,
JsonRpcErrorCode,
PluginRpcErrorCode,
PluginInvocationScope,
PluginInvocationContext,
WorkerHostCallContext,
InitializeParams,
InitializeResult,
ConfigChangedParams,
@ -218,6 +222,17 @@ export type {
PluginIssueSubtree,
PluginIssueSummariesClient,
PluginAgentsClient,
PluginAccessClient,
PluginAccessMembersClient,
PluginAccessInvitesClient,
PluginAccessMember,
PluginAccessInvite,
PluginAuthorizationClient,
PluginAuthorizationPolicySummary,
PluginAuthorizationPolicyRecord,
PluginAssignmentPreviewInput,
PluginAuthorizationDecisionResult,
PluginAuthorizationAuditEntry,
PluginAgentSessionsClient,
AgentSession,
AgentSessionEvent,
@ -253,7 +268,12 @@ export type {
IssueDocumentSummary,
Agent,
Goal,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
PluginDatabaseClient,
HumanCompanyMembershipRole,
MembershipStatus,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared
@ -353,6 +373,7 @@ export {
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_STATE_SCOPE_KINDS,
PLUGIN_JOB_STATUSES,
PLUGIN_JOB_RUN_STATUSES,
@ -360,4 +381,9 @@ export {
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES,
PERMISSION_KEYS,
HUMAN_COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
MEMBERSHIP_STATUSES,
PRINCIPAL_TYPES,
} from "@paperclipai/shared";

View file

@ -39,6 +39,7 @@ import type {
Agent,
Goal,
PluginLocalFolderDeclaration,
PrincipalPermissionGrant,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@ -57,6 +58,13 @@ import type {
ToolResult,
PluginLocalFolderListing,
PluginLocalFolderStatus,
PluginAccessInvite,
PluginAccessMember,
PluginAssignmentPreviewInput,
PluginAuthorizationAuditEntry,
PluginAuthorizationDecisionResult,
PluginAuthorizationPolicyRecord,
PluginAuthorizationPolicySummary,
} from "./types.js";
import type {
PluginHealthDiagnostics,
@ -96,6 +104,14 @@ export interface JsonRpcRequest<
readonly method: TMethod;
/** Structured parameters for the method call. */
readonly params: TParams;
/**
* Host-issued metadata for the top-level plugin invocation that is currently
* executing. The worker treats this as opaque and echoes only the id on
* workerhost calls made from the same async execution context.
*/
readonly paperclipInvocation?: PluginInvocationContext;
/** Opaque top-level invocation id echoed by worker→host requests. */
readonly paperclipInvocationId?: string;
}
/**
@ -156,6 +172,13 @@ export interface JsonRpcNotification<
readonly method: TMethod;
/** Structured parameters for the notification. */
readonly params: TParams;
/**
* Host-issued metadata for hostworker push notifications such as events.
* Workerhost notifications echo only `paperclipInvocationId`.
*/
readonly paperclipInvocation?: PluginInvocationContext;
/** Opaque top-level invocation id echoed by worker→host notifications. */
readonly paperclipInvocationId?: string;
}
/**
@ -217,6 +240,36 @@ export const PLUGIN_RPC_ERROR_CODES = {
export type PluginRpcErrorCode =
(typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES];
// ---------------------------------------------------------------------------
// Invocation scope metadata
// ---------------------------------------------------------------------------
/**
* Company scope attached by the host to one top-level plugin invocation.
* Absence of this metadata means the invocation is instance/global scoped.
*/
export interface PluginInvocationScope {
companyId: string;
}
/**
* Opaque invocation metadata generated by the host. Workers must not derive or
* mutate this. They only echo the id on nested workerhost RPC calls.
*/
export interface PluginInvocationContext {
id: string;
scope: PluginInvocationScope;
}
/**
* Context provided to host-side workerhost handlers after the worker echoes a
* host-issued invocation id.
*/
export interface WorkerHostCallContext {
invocationScope?: PluginInvocationScope | null;
invalidInvocationScope?: boolean;
}
// ---------------------------------------------------------------------------
// Host → Worker Method Signatures (§13 Host-Worker Protocol)
// ---------------------------------------------------------------------------
@ -302,6 +355,8 @@ export interface RunJobParams {
export interface GetDataParams {
/** Plugin-defined data key (e.g. `"sync-health"`). */
key: string;
/** Host-authorized active company scope, when this bridge call is company-scoped. */
companyId?: string | null;
/** Context and query parameters from the UI. */
params: Record<string, unknown>;
/** Optional launcher/container metadata from the host render environment. */
@ -316,6 +371,8 @@ export interface GetDataParams {
export interface PerformActionParams {
/** Plugin-defined action key (e.g. `"resync"`). */
key: string;
/** Host-authorized active company scope, when this bridge call is company-scoped. */
companyId?: string | null;
/** Action parameters from the UI. */
params: Record<string, unknown>;
/** Optional launcher/container metadata from the host render environment. */
@ -1128,6 +1185,105 @@ export interface WorkerToHostMethods {
},
result: Goal,
];
// Access
"access.members.list": [
params: { companyId: string; includeArchived?: boolean },
result: PluginAccessMember[],
];
"access.members.get": [
params: { memberId: string; companyId: string },
result: PluginAccessMember | null,
];
"access.members.update": [
params: {
memberId: string;
companyId: string;
patch: {
membershipRole?: string | null;
status?: "pending" | "active" | "suspended";
};
},
result: PluginAccessMember,
];
"access.invites.list": [
params: {
companyId: string;
state?: "active" | "revoked" | "accepted" | "expired";
limit?: number;
offset?: number;
},
result: { invites: PluginAccessInvite[]; nextOffset: number | null },
];
"access.invites.create": [
params: {
companyId: string;
allowedJoinTypes?: "human" | "agent" | "both";
humanRole?: string | null;
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
},
result: PluginAccessInvite & { token: string },
];
"access.invites.revoke": [
params: { inviteId: string; companyId: string },
result: PluginAccessInvite,
];
// Authorization
"authorization.grants.list": [
params: { companyId: string; principalType?: string; principalId?: string },
result: PrincipalPermissionGrant[],
];
"authorization.grants.set": [
params: {
companyId: string;
principalType: string;
principalId: string;
grants: Array<{ permissionKey: string; scope?: Record<string, unknown> | null }>;
grantedByUserId?: string | null;
},
result: PrincipalPermissionGrant[],
];
"authorization.policies.summary": [
params: { companyId: string },
result: PluginAuthorizationPolicySummary,
];
"authorization.policies.get": [
params: { companyId: string; resourceType: "company" | "agent" | "project" | "issue"; resourceId: string },
result: PluginAuthorizationPolicyRecord | null,
];
"authorization.policies.update": [
params: {
companyId: string;
resourceType: "company" | "agent" | "project" | "issue";
resourceId: string;
policy: Record<string, unknown> | null;
},
result: PluginAuthorizationPolicyRecord,
];
"authorization.policies.previewAssignment": [
params: PluginAssignmentPreviewInput,
result: PluginAuthorizationDecisionResult,
];
"authorization.policies.explainAssignment": [
params: PluginAssignmentPreviewInput,
result: PluginAuthorizationDecisionResult,
];
"authorization.audit.search": [
params: {
companyId: string;
action?: string;
actorType?: string;
actorId?: string;
entityType?: string;
entityId?: string;
decision?: string;
limit?: number;
offset?: number;
},
result: PluginAuthorizationAuditEntry[],
];
}
/** Union of all worker→host method names. */

View file

@ -38,6 +38,10 @@ import type {
AgentSessionEvent,
PluginLocalFolderEntry,
PluginLocalFolderStatus,
PluginAccessMember,
PrincipalPermissionGrant,
PermissionKey,
PrincipalType,
} from "./types.js";
import type {
PluginEnvironmentValidateConfigParams,
@ -73,7 +77,7 @@ export interface TestHarnessLogEntry {
export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext;
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
/** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
seed(input: {
companies?: Company[];
projects?: Project[];
@ -83,6 +87,8 @@ export interface TestHarness {
goals?: Goal[];
projectWorkspaces?: PluginWorkspace[];
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
accessMembers?: PluginAccessMember[];
principalGrants?: PrincipalPermissionGrant[];
}): void;
setConfig(config: Record<string, unknown>): void;
/** Dispatch a host or plugin event to registered handlers. */
@ -440,6 +446,39 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const issueDocuments = new Map<string, IssueDocument>();
const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>();
const accessMembers = new Map<string, PluginAccessMember>();
const principalGrants = new Map<string, PrincipalPermissionGrant[]>();
function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) {
return `${companyId}:${principalType}:${principalId}`;
}
function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) {
return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? [];
}
function setPrincipalGrants(
companyId: string,
principalType: PrincipalType,
principalId: string,
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>,
) {
const stamped = grants.map((grant) => ({
principalType,
principalId,
permissionKey: grant.permissionKey,
scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null,
})) as PrincipalPermissionGrant[];
principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped);
const member = [...accessMembers.values()].find(
(entry) =>
entry.companyId === companyId
&& entry.principalType === principalType
&& entry.principalId === principalId,
);
if (member) {
accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() });
}
return stamped;
}
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
@ -1983,6 +2022,156 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return updated;
},
},
access: {
members: {
async list(input) {
requireCapability(manifest, capabilitySet, "access.members.read");
const cid = requireCompanyId(input.companyId);
const includeArchived = input.includeArchived === true;
return [...accessMembers.values()]
.filter((member) => member.companyId === cid)
.filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"]))
.map((member) => ({
...member,
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
}));
},
async get(memberId, companyId) {
requireCapability(manifest, capabilitySet, "access.members.read");
const cid = requireCompanyId(companyId);
const member = accessMembers.get(memberId);
if (!member || member.companyId !== cid) return null;
return {
...member,
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
};
},
async update(memberId, patch, companyId) {
requireCapability(manifest, capabilitySet, "access.members.write");
const cid = requireCompanyId(companyId);
const member = accessMembers.get(memberId);
if (!member || member.companyId !== cid) {
throw new Error(`Membership not found: ${memberId}`);
}
const updated: PluginAccessMember = {
...member,
membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole,
status: patch.status === undefined ? member.status : patch.status,
updatedAt: new Date().toISOString(),
};
accessMembers.set(memberId, updated);
return {
...updated,
grants: getPrincipalGrants(cid, updated.principalType, updated.principalId),
};
},
},
invites: {
async list(input) {
requireCapability(manifest, capabilitySet, "access.invites.read");
requireCompanyId(input.companyId);
return { invites: [], nextOffset: null };
},
async create(input) {
requireCapability(manifest, capabilitySet, "access.invites.write");
requireCompanyId(input.companyId);
throw new Error("Invite creation is not implemented in the plugin test harness");
},
async revoke(inviteId, companyId) {
requireCapability(manifest, capabilitySet, "access.invites.write");
requireCompanyId(companyId);
throw new Error(`Invite not found: ${inviteId}`);
},
},
},
authorization: {
grants: {
async list(input) {
requireCapability(manifest, capabilitySet, "authorization.grants.read");
const cid = requireCompanyId(input.companyId);
if (input.principalType && input.principalId) {
return getPrincipalGrants(cid, input.principalType, input.principalId);
}
const out: PrincipalPermissionGrant[] = [];
for (const [key, grants] of principalGrants.entries()) {
if (!key.startsWith(`${cid}:`)) continue;
for (const grant of grants) {
if (input.principalType && grant.principalType !== input.principalType) continue;
if (input.principalId && grant.principalId !== input.principalId) continue;
out.push(grant);
}
}
return out;
},
async set(input) {
requireCapability(manifest, capabilitySet, "authorization.grants.write");
const cid = requireCompanyId(input.companyId);
return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants);
},
},
policies: {
async summary(companyId) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
const cid = requireCompanyId(companyId);
const members = [...accessMembers.values()].filter((member) => member.companyId === cid);
let grantCount = 0;
for (const [key, grants] of principalGrants.entries()) {
if (key.startsWith(`${cid}:`)) grantCount += grants.length;
}
return {
companyId: cid,
permissionsMode: "simple",
memberCount: members.length,
activeMemberCount: members.filter((member) => member.status === "active").length,
grantCount,
advancedPolicyAvailable: false,
};
},
async get(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return null;
},
async update(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.write");
const cid = requireCompanyId(input.companyId);
return {
companyId: cid,
resourceType: input.resourceType,
resourceId: input.resourceId,
policy: input.policy,
updatedAt: new Date().toISOString(),
};
},
async previewAssignment(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return {
allowed: true,
action: "issue.assign",
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
reason: "simple_mode",
};
},
async explainAssignment(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return {
allowed: true,
action: "issue.assign",
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
reason: "simple_mode",
};
},
},
audit: {
async search(input) {
requireCapability(manifest, capabilitySet, "authorization.audit.read");
requireCompanyId(input.companyId);
return [];
},
},
},
data: {
register(key, handler) {
dataHandlers.set(key, handler);
@ -2065,6 +2254,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
projectWorkspaces.set(row.projectId, list);
}
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row);
for (const row of input.principalGrants ?? []) {
const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? [];
list.push(row);
principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list);
}
},
setConfig(config) {
currentConfig = { ...config };

View file

@ -39,6 +39,12 @@ import type {
RoutineRun,
Agent,
Goal,
HumanCompanyMembershipRole,
InviteJoinType,
MembershipStatus,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@ -120,6 +126,12 @@ export type {
IssueSurfaceVisibility,
Agent,
Goal,
HumanCompanyMembershipRole,
InviteJoinType,
MembershipStatus,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@ -1576,6 +1588,169 @@ export interface PluginGoalsClient {
): Promise<Goal>;
}
// ---------------------------------------------------------------------------
// Access and Authorization
// ---------------------------------------------------------------------------
export interface PluginAccessMember {
id: string;
companyId: string;
principalType: PrincipalType;
principalId: string;
status: MembershipStatus;
membershipRole: string | null;
grants: PrincipalPermissionGrant[];
createdAt: Date | string;
updatedAt: Date | string;
}
export interface PluginAccessInvite {
id: string;
companyId: string | null;
inviteType: string;
allowedJoinTypes: InviteJoinType;
defaultsPayload: Record<string, unknown> | null;
expiresAt: Date | string;
invitedByUserId: string | null;
revokedAt: Date | string | null;
acceptedAt: Date | string | null;
createdAt: Date | string;
updatedAt: Date | string;
state: "active" | "revoked" | "accepted" | "expired";
}
export interface PluginAccessMembersClient {
list(input: { companyId: string; includeArchived?: boolean }): Promise<PluginAccessMember[]>;
get(memberId: string, companyId: string): Promise<PluginAccessMember | null>;
update(
memberId: string,
patch: {
membershipRole?: HumanCompanyMembershipRole | null;
status?: Extract<MembershipStatus, "pending" | "active" | "suspended">;
},
companyId: string,
): Promise<PluginAccessMember>;
}
export interface PluginAccessInvitesClient {
list(input: {
companyId: string;
state?: PluginAccessInvite["state"];
limit?: number;
offset?: number;
}): Promise<{ invites: PluginAccessInvite[]; nextOffset: number | null }>;
create(input: {
companyId: string;
allowedJoinTypes?: InviteJoinType;
humanRole?: HumanCompanyMembershipRole | null;
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
}): Promise<PluginAccessInvite & { token: string }>;
revoke(inviteId: string, companyId: string): Promise<PluginAccessInvite>;
}
export interface PluginAccessClient {
/** Read and update company memberships. Requires `access.members.*`. */
members: PluginAccessMembersClient;
/** Read, create, and revoke company invites. Requires `access.invites.*`. */
invites: PluginAccessInvitesClient;
}
export interface PluginAuthorizationPolicySummary {
companyId: string;
permissionsMode: "simple";
memberCount: number;
activeMemberCount: number;
grantCount: number;
advancedPolicyAvailable: false;
}
export interface PluginAuthorizationPolicyRecord {
resourceType: "company" | "agent" | "project" | "issue";
resourceId: string;
companyId: string;
policy: Record<string, unknown> | null;
updatedAt: Date | string | null;
}
export interface PluginAssignmentPreviewInput {
companyId: string;
actor:
| { type: "board"; userId?: string | null; companyIds?: string[]; isInstanceAdmin?: boolean }
| { type: "agent"; agentId: string; companyId: string };
target: {
issueId?: string | null;
projectId?: string | null;
parentIssueId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
status?: string | null;
};
}
export interface PluginAuthorizationDecisionResult {
allowed: boolean;
action: string;
explanation: string;
reason: string;
grant?: {
principalType: PrincipalType;
principalId: string;
permissionKey: PermissionKey;
scope: Record<string, unknown> | null;
};
}
export interface PluginAuthorizationAuditEntry {
id: string;
companyId: string;
actorType: string;
actorId: string;
action: string;
entityType: string;
entityId: string;
details: Record<string, unknown> | null;
createdAt: Date | string;
}
export interface PluginAuthorizationClient {
grants: {
list(input: { companyId: string; principalType?: PrincipalType; principalId?: string }): Promise<PrincipalPermissionGrant[]>;
set(input: {
companyId: string;
principalType: PrincipalType;
principalId: string;
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>;
grantedByUserId?: string | null;
}): Promise<PrincipalPermissionGrant[]>;
};
policies: {
summary(companyId: string): Promise<PluginAuthorizationPolicySummary>;
get(input: { companyId: string; resourceType: PluginAuthorizationPolicyRecord["resourceType"]; resourceId: string }): Promise<PluginAuthorizationPolicyRecord | null>;
update(input: {
companyId: string;
resourceType: PluginAuthorizationPolicyRecord["resourceType"];
resourceId: string;
policy: Record<string, unknown> | null;
}): Promise<PluginAuthorizationPolicyRecord>;
previewAssignment(input: PluginAssignmentPreviewInput): Promise<PluginAuthorizationDecisionResult>;
explainAssignment(input: PluginAssignmentPreviewInput): Promise<PluginAuthorizationDecisionResult>;
};
audit: {
search(input: {
companyId: string;
action?: string;
actorType?: string;
actorId?: string;
entityType?: string;
entityId?: string;
decision?: string;
limit?: number;
offset?: number;
}): Promise<PluginAuthorizationAuditEntry[]>;
};
}
// ---------------------------------------------------------------------------
// Streaming (worker → UI push channel)
// ---------------------------------------------------------------------------
@ -1716,6 +1891,12 @@ export interface PluginContext {
/** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */
goals: PluginGoalsClient;
/** Read and manage access memberships and invites. Requires `access.*` capabilities. */
access: PluginAccessClient;
/** Read and manage authorization grants, policy summaries, previews, and audit entries. Requires `authorization.*` capabilities. */
authorization: PluginAuthorizationClient;
/** Register getData handlers for the plugin's UI components. */
data: PluginDataClient;

View file

@ -146,6 +146,7 @@ export type {
// Slot component prop interfaces
export type {
PluginPageProps,
PluginCompanySettingsPageProps,
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,

View file

@ -229,6 +229,18 @@ export interface PluginPageProps {
context: PluginHostContext;
}
/**
* Props passed to a plugin company settings page component.
*
* A company settings page is mounted at
* `/:companyPrefix/company/settings/:routePath` and always receives the active
* company id and prefix when available.
*/
export interface PluginCompanySettingsPageProps {
/** The current host context, including company id and prefix. */
context: PluginHostContext;
}
/**
* Props passed to a plugin dashboard widget component.
*

View file

@ -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";
@ -66,6 +67,7 @@ import type {
} from "./types.js";
import type {
JsonRpcId,
JsonRpcNotification,
JsonRpcRequest,
JsonRpcResponse,
InitializeParams,
@ -85,6 +87,7 @@ import type {
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentProbeParams,
PluginInvocationContext,
WorkerToHostMethodName,
WorkerToHostMethods,
} from "./protocol.js";
@ -279,6 +282,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
let manifest: PaperclipPluginManifestV1 | null = null;
let currentConfig: Record<string, unknown> = {};
let databaseNamespace: string | null = null;
const invocationContextStorage = new AsyncLocalStorage<PluginInvocationContext>();
// Plugin handler registrations (populated during setup())
const eventHandlers: EventRegistration[] = [];
@ -365,7 +369,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
try {
const request = createRequest(method, params, id);
const activeInvocation = invocationContextStorage.getStore();
const request = {
...createRequest(method, params, id),
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
};
sendMessage(request);
} catch (err) {
settle(reject, err instanceof Error ? err : new Error(String(err)));
@ -378,7 +386,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
*/
function notifyHost(method: string, params: unknown): void {
try {
sendMessage(createNotification(method, params));
const activeInvocation = invocationContextStorage.getStore();
sendMessage({
...createNotification(method, params),
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
});
} catch {
// Swallow — the host may have closed stdin
}
@ -1086,6 +1098,85 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
access: {
members: {
async list(input) {
return callHost("access.members.list", {
companyId: input.companyId,
includeArchived: input.includeArchived,
});
},
async get(memberId: string, companyId: string) {
return callHost("access.members.get", { memberId, companyId });
},
async update(memberId: string, patch, companyId: string) {
return callHost("access.members.update", { memberId, patch, companyId });
},
},
invites: {
async list(input) {
return callHost("access.invites.list", {
companyId: input.companyId,
state: input.state,
limit: input.limit,
offset: input.offset,
});
},
async create(input) {
return callHost("access.invites.create", {
companyId: input.companyId,
allowedJoinTypes: input.allowedJoinTypes,
humanRole: input.humanRole,
defaultsPayload: input.defaultsPayload,
agentMessage: input.agentMessage,
});
},
async revoke(inviteId: string, companyId: string) {
return callHost("access.invites.revoke", { inviteId, companyId });
},
},
},
authorization: {
grants: {
async list(input) {
return callHost("authorization.grants.list", input);
},
async set(input) {
return callHost("authorization.grants.set", input);
},
},
policies: {
async summary(companyId: string) {
return callHost("authorization.policies.summary", { companyId });
},
async get(input) {
return callHost("authorization.policies.get", input);
},
async update(input) {
return callHost("authorization.policies.update", input);
},
async previewAssignment(input) {
return callHost("authorization.policies.previewAssignment", input);
},
async explainAssignment(input) {
return callHost("authorization.policies.explainAssignment", input);
},
},
audit: {
async search(input) {
return callHost("authorization.audit.search", input);
},
},
},
data: {
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
dataHandlers.set(key, handler);
@ -1175,7 +1266,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const { id, method, params } = request;
try {
const result = await dispatchMethod(method, params);
const invoke = () => dispatchMethod(method, params);
const result = request.paperclipInvocation
? await invocationContextStorage.run(request.paperclipInvocation, invoke)
: await invoke();
sendMessage(createSuccessResponse(id, result ?? null));
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
@ -1413,11 +1507,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No data handler registered for key "${params.key}"`);
}
return handler(
params.renderEnvironment === undefined
? params.params
: { ...params.params, renderEnvironment: params.renderEnvironment },
);
return handler({
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
}
async function handlePerformAction(params: PerformActionParams): Promise<unknown> {
@ -1425,11 +1519,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No action handler registered for key "${params.key}"`);
}
return handler(
params.renderEnvironment === undefined
? params.params
: { ...params.params, renderEnvironment: params.renderEnvironment },
);
return handler({
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
}
async function handleExecuteTool(params: ExecuteToolParams): Promise<ToolResult> {
@ -1597,14 +1691,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
} else if (isJsonRpcNotification(message)) {
// Dispatch host→worker push notifications
const notif = message as { method: string; params?: unknown };
const notif = message as JsonRpcNotification & { method: string; params?: unknown };
const runNotification = (fn: () => void | Promise<void>) => {
if (notif.paperclipInvocation) {
return invocationContextStorage.run(notif.paperclipInvocation, fn);
}
return fn();
};
if (notif.method === "agents.sessions.event" && notif.params) {
const event = notif.params as AgentSessionEvent;
const cb = sessionEventCallbacks.get(event.sessionId);
if (cb) cb(event);
} else if (notif.method === "onEvent" && notif.params) {
// Plugin event bus notifications — dispatch to registered event handlers
handleOnEvent(notif.params as OnEventParams).catch((err) => {
Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {
notifyHost("log", {
level: "error",
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,