mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +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
|
|
@ -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)}`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue