mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
[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:
parent
c91a062326
commit
38c185fb8b
102 changed files with 6744 additions and 395 deletions
|
|
@ -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 worker→host 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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue