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

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