feat: add individual agent interaction permissions (#20411)

Add eleven sparse incoming and outgoing restrictions, human CLI/API administration, shared mutation enforcement, and pending-delivery checks. Preserve existing defaults, agent settings, and existing authorization boundaries.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-09 20:34:04 -06:00
1 parent 4158799d1b
commit 5bad074baa
97 files changed
+3590 -433

No files matched your search

@@ -12,6 +12,7 @@ import type { LifecycleEffects } from "./effects.js";
export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions, publications: ActivityPublication[] = []) {
if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Configuration integration requires a database transaction");
assertConfigurationFields(data);
options = await effects.lockConfigurationInteractions(tx, id, data, options);
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
if (!current) return null;
if (options?.expectedConfigurationRevision && options.expectedConfigurationRevision !== agentConfigurationRevision(current)) {
@@ -16,6 +16,7 @@ export interface LifecycleEffects {
beforeConnectionEligibilityRestore?(db: Db, companyId: string, agentId: string): Promise<void>;
reportFailure(context: LifecycleFailure, error: unknown): void;
deleteDependencies(db: Db, companyId: string, agentId: string): Promise<void>;
lockConfigurationInteractions(db: Db, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<UpdateAgentOptions | undefined>;
prepareConfiguration(db: Db, current: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch>;
completeConfiguration(db: Db, before: AgentConfigurationRecord, after: AgentConfigurationRecord, patch: AgentConfigurationPatch, options: UpdateAgentOptions | undefined, publications: ActivityPublication[]): Promise<void>;
transaction<T>(db: Db, companyId: string, work: (tx: Db, publications: ActivityPublication[]) => Promise<T>): Promise<T>;
@@ -1,3 +1,4 @@
import { preserveAgentInteractionPermissions } from "../../../lib/agent-interaction-permissions.js";
import type { LifecycleEffects } from "./effects.js";
import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
@@ -43,6 +44,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
const createdStatus=status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated" : "paused";
const values: typeof agents.$inferInsert = {
...prepared,
...(setup ? { permissions: preserveAgentInteractionPermissions(setup.permissions, prepared.permissions) } : {}),
createdByUserId: setup?.createdByUserId ?? options?.createdByUserId ?? null,
lifecycleState,
lifecycleVersion: setup ? setup.lifecycleVersion + 1 : 1,
@@ -1,3 +1,5 @@
import type { ActivityPublication } from "../../types/activity-publication.js";
import { AgentLifecycleConflict } from "./domain/policy.js";
import { hireApprovalService as approvalRecords, type HireDecisionTarget } from "./adapters/approvals.js";
import { agents, companies } from "@paperclipai/db";
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm";
@@ -43,6 +45,24 @@ export function processAgentLifecycle(db: Db, id: string): Promise<void> {
return workers.get(db)?.process(id) ?? Promise.resolve();
}
export function initializeImportedAgentConfigurationInTransaction(tx: Db, effects: LifecycleEffects, publications: ActivityPublication[],
id: string, data: Pick<AgentConfigurationPatch, "adapterConfig" | "reportsTo">) {
if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Imported configuration requires a database transaction");
if (Object.keys(data).some(key => key !== "adapterConfig" && key !== "reportsTo")) throw new AgentLifecycleConflict("Only initial imported instructions and reporting links can be initialized");
return updateAgentConfiguration(tx, effects, id, data, undefined, publications);
}
export async function requestAgentHireInTransaction(tx: Db, effects: LifecycleEffects, publications: ActivityPublication[],
...args: Parameters<ReturnType<typeof agentRecords>["create"]>) {
if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Imported hires require a database transaction");
const records = agentRecords(tx, { ...effects, transaction: async (database, companyId, work) => {
const [company] = await database.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("no key update");
if (!company) throw new AgentLifecycleConflict("Import company is unavailable");
return work(database, publications);
} });
return records.create(...args);
}
export function createAgentLifecycle(db: Db, effects: LifecycleEffects) {
assertRootDatabase(db);
const records = agentRecords(db, effects);
@@ -11,6 +11,7 @@ import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/exter
import { createQueuedCommentIssueLockWriter } from "./queued-comment-postgres.js";
import type { QueuedCommentQueuePostgresAdapterDeps } from "./queued-comment-postgres.js";
import { QueuedCommentMutationError } from "../application/queued-comment-use-cases.js";
import { buildAgentMentionHref } from "@paperclipai/shared";
// Proves the same two properties the release-half adapter test proves for
// this module's other transaction: the one company the caller names in
@@ -138,6 +139,30 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => {
return id;
}
it("checks the authenticated editor and changed mentions atomically before updating a queued comment", async () => {
const companyId = await seedCompany();
const recipient = await seedAgent({ companyId }), sender = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: recipient });
const commentId = await seedComment({ companyId, issueId, authorUserId: "user-1" });
const wakeId = await seedDeferredWake({ companyId, agentId: recipient, issueId, commentIds: [commentId] });
const issueLock = createQueuedCommentIssueLockWriter(db, noopDeps);
const input = { issue: { id: issueId, companyId, assigneeAgentId: recipient, executionRunId: null },
actor: { actorType: "agent" as const, actorId: sender, agentId: sender, runId: null, agentApiKeyId: null }, queueId: wakeId };
await db.update(agents).set({ permissions: { interactions: { canMentionAgents: false } } }).where(eq(agents.id, sender));
await expect(issueLock.withLockedQueue(input, async (_locked, tx) => tx.updateCommentBody({ issueId, commentId,
body: `[@Recipient](${buildAgentMentionHref(recipient)})`, updatedAt: new Date() }))).rejects.toMatchObject({ status: 403,
details: expect.objectContaining({ permission: "canMentionAgents", actorAgentId: sender }) });
await db.update(agents).set({ permissions: { interactions: { canReceiveCommentsFromAgents: false } } }).where(eq(agents.id, recipient));
await expect(issueLock.withLockedQueue(input, async (_locked, tx) => tx.deleteComment({ issueId, commentId })))
.rejects.toMatchObject({ status: 403, details: expect.objectContaining({ permission: "canReceiveCommentsFromAgents" }) });
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0]?.body).toBe("queued message");
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)))[0]?.payload)
.toMatchObject({ _paperclipWakeContext: { wakeCommentIds: [commentId] } });
await issueLock.withLockedQueue({ ...input, actor: { ...input.actor, actorType: "user", actorId: "user-1", agentId: null } },
async (_locked, tx) => tx.updateCommentBody({ issueId, commentId, body: "Human edit", updatedAt: new Date() }));
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0]?.body).toBe("Human edit");
});
it("scopes the wake lookup to its own company: a foreign-company issue context resolves not_pending and deletes nothing", async () => {
const companyId = await seedCompany();
const otherCompanyId = await seedCompany();
@@ -1,4 +1,5 @@
import { notifyStrandedAgentStatus } from "../../../services/live-run-recovery-notifications.js";
import { assertAgentInteraction } from "../../../services/agent-interaction-policy.js";
import { notifyExecutionRunWork } from "../../../services/execution-control-notifications.js";
import { notifyStatusCardIssueChange } from "../../../services/status-card-notifications.js";
import { notifyExternalAgentSettlement } from "../../external-agents/index.js";
@@ -26,6 +27,7 @@ import { parseObject, readNonEmptyString } from "../domain/values.js";
import { QueuedCommentMutationError } from "../application/queued-comment-use-cases.js";
import type {
LockedQueuedCommentState,
QueuedCommentActor,
QueuedCommentActivityLogInput,
QueuedCommentActivityPublication,
QueuedCommentIssueLockWriter,
@@ -56,13 +58,18 @@ export type QueuedCommentQueuePostgresAdapterDeps = {
syncCommentExternalObjectsSafely(commentId: string, tx: Db): Promise<void>;
};
function buildTransaction(tx: Db, companyId: string, issueId: string, deps: QueuedCommentQueuePostgresAdapterDeps, wakeAgentId: string): QueuedCommentQueueTransaction {
function buildTransaction(tx: Db, companyId: string, issueId: string, deps: QueuedCommentQueuePostgresAdapterDeps, wakeAgentId: string, actor: QueuedCommentActor): QueuedCommentQueueTransaction {
// Messages the active run took into its first turn leave the visible queue
// but stay on the wake until that turn is accepted, so a run that fails
// before then still leaves them queued. Queue edits keep them.
let heldByFirstTurn: string[] = [];
return {
async updateCommentBody({ issueId, commentId, body, updatedAt }) {
const [existing] = await tx.select({ body: issueComments.body }).from(issueComments).where(and(
eq(issueComments.id, commentId), eq(issueComments.issueId, issueId), eq(issueComments.companyId, companyId)));
if (!existing) return false;
await assertAgentInteraction(tx, { companyId, actorAgentId: actor.actorType === "agent" ? actor.actorId : null,
operation: "comment", issueId, texts: body === existing.body ? [] : [body], lock: true });
await notifyStatusCardIssueChange(tx, companyId);
const updated = await tx
.update(issueComments)
@@ -334,7 +341,7 @@ export function createQueuedCommentIssueLockWriter(db: Db, deps: QueuedCommentQu
.then((rows) => rows[0] ?? null)
: null;
const transaction = buildTransaction(tx, companyId, input.issue.id, deps, wakeRow.agentId);
const transaction = buildTransaction(tx, companyId, input.issue.id, deps, wakeRow.agentId, input.actor);
const wake = toWakeRow(wakeRow);
const queueRun = queueRunRow ? toRunRow(queueRunRow) : null;
const activeRun = activeRunRow ? toRunRow(activeRunRow) : null;
@@ -349,6 +356,8 @@ export function createQueuedCommentIssueLockWriter(db: Db, deps: QueuedCommentQu
});
const locked: LockedQueuedCommentState = { wake, state, queueRun, activeRun, queue };
await assertAgentInteraction(tx, { companyId, actorAgentId: input.actor.actorType === "agent" ? input.actor.actorId : null,
operation: "comment", issueId: input.issue.id, lock: true });
return fn(locked, transaction);
});
},