mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
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>
261 lines
20 KiB
TypeScript
261 lines
20 KiB
TypeScript
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { companyPortabilityService } from "../services/company-portability.js";
|
|
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { createToolGatewayService } from "../services/tool-gateway.js";
|
|
import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js";
|
|
import { updateAgentInteractionPermissions } from "../services/agent-interaction-permissions.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeFileStorageDb, principalPermissionGrants,
|
|
issues, issueComments, toolApplications, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileBindings, toolMcpGateways,
|
|
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
|
import { AGENT_INTERACTION_PERMISSION_KEYS, resolveAgentInteractionPermissions } from "@paperclipai/shared";
|
|
import { agentInteractionPermissionRoutes } from "../routes/agent-interaction-permissions.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { agentService } from "../services/agents.js";
|
|
import { preserveAgentInteractionPermissions, assertNoInitialAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js";
|
|
|
|
const execute = promisify(execFile);
|
|
|
|
describe("individual agent interaction permissions", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let storageHome: string;
|
|
beforeAll(async () => {
|
|
storageHome = await mkdtemp(join(tmpdir(), "interaction-permissions-home-"));
|
|
vi.stubEnv("PAPERCLIP_HOME", storageHome);
|
|
database = await startEmbeddedPostgresTestDatabase("agent-interaction-permissions-");
|
|
db = createDb(database.connectionString);
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
afterAll(async () => { await closeFileStorageDb(db, 1); await db?.$client.end(); await database?.cleanup(); await rm(storageHome, { recursive: true, force: true }); vi.unstubAllEnvs(); });
|
|
|
|
async function seed() {
|
|
const companyId = randomUUID(), agentId = randomUUID(), peerId = randomUUID(), userId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Interaction permissions", issuePrefix: `I${companyId.slice(0, 6)}` });
|
|
await db.insert(authUsers).values({ id: userId, name: "Permission manager", email: `${userId}@example.com`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(agents).values([agentId, peerId].map(id => ({ id, companyId, name: id, adapterType: "process", status: "idle",
|
|
permissions: { canCreateAgents: true, unrelated: "preserved" } })));
|
|
await db.insert(companyMemberships).values([
|
|
{ companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" },
|
|
...[agentId, peerId].map(principalId => ({ companyId, principalType: "agent", principalId, membershipRole: "member", status: "active" })),
|
|
]);
|
|
await db.insert(principalPermissionGrants).values(["users:manage_permissions", "agents:read"].map(permissionKey => ({
|
|
companyId, principalType: "user", principalId: userId, permissionKey,
|
|
})));
|
|
return { companyId, agentId, peerId, userId };
|
|
}
|
|
type Fixture = Awaited<ReturnType<typeof seed>>;
|
|
const board = (f: Fixture): express.Request["actor"] => ({ type: "board", source: "session", userId: f.userId,
|
|
companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "member", status: "active" }] });
|
|
const actor = (f: Fixture): express.Request["actor"] => ({ type: "agent", source: "agent_key", agentId: f.agentId, companyId: f.companyId });
|
|
function app(identity: express.Request["actor"]) {
|
|
const api = express();
|
|
api.use(express.json());
|
|
api.use((req, _res, next) => { req.actor = identity; next(); });
|
|
api.use("/api", agentInteractionPermissionRoutes(db));
|
|
api.use(errorHandler);
|
|
return api;
|
|
}
|
|
const path = (f: Fixture) => `/api/agents/${f.agentId}/interaction-permissions`;
|
|
const read = (f: Fixture) => db.select().from(agents).where(eq(agents.id, f.agentId)).then(rows => rows[0]!);
|
|
|
|
it("resolves absent settings without writing defaults, changes only supplied keys, and removes overrides with null", async () => {
|
|
const f = await seed(), api = app(board(f));
|
|
const initial = await read(f);
|
|
const response = await request(api).get(path(f));
|
|
expect(response.status, response.text).toBe(200);
|
|
expect(response.body).toEqual({ agentId: f.agentId, companyId: f.companyId, overrides: {}, effective: resolveAgentInteractionPermissions({}) });
|
|
expect((await read(f)).permissions).toEqual(initial.permissions);
|
|
const first = await request(api).patch(path(f)).send({ canReceiveCommentsFromAgents: false });
|
|
expect(first.status, first.text).toBe(200);
|
|
expect(first.body.overrides).toEqual({ canReceiveCommentsFromAgents: false });
|
|
const second = await request(api).patch(path(f)).send({ canAssignTasksToHumans: true });
|
|
expect(second.body.overrides).toEqual({ canReceiveCommentsFromAgents: false, canAssignTasksToHumans: true });
|
|
const reset = await request(api).patch(path(f)).send({ canReceiveCommentsFromAgents: null, canAssignTasksToHumans: null });
|
|
expect(reset.status, reset.text).toBe(200);
|
|
expect(reset.body.overrides).toEqual({});
|
|
expect((await read(f)).permissions).toEqual(initial.permissions);
|
|
const audit = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.agentId), eq(activityLog.action, "agent.interaction_permissions_updated")));
|
|
expect(audit).toHaveLength(3);
|
|
expect(audit.every(row => row.actorId === f.userId)).toBe(true);
|
|
});
|
|
|
|
it("allows own configuration reads but denies agent/CEO changes, foreign users, and humans without permission management", async () => {
|
|
const f = await seed(), foreign = await seed();
|
|
await db.update(agents).set({ role: "ceo" }).where(eq(agents.id, f.agentId));
|
|
expect((await request(app(actor(f))).get(path(f))).status).toBe(200);
|
|
expect((await request(app(actor(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(403);
|
|
expect((await request(app(board(foreign))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(404);
|
|
await expect(withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.peerId }, () =>
|
|
updateAgentInteractionPermissions(db, board(f), f.companyId, f.agentId, { canMentionAgents: false })))
|
|
.rejects.toMatchObject({ status: 403 });
|
|
await db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.userId), eq(principalPermissionGrants.permissionKey, "users:manage_permissions")));
|
|
expect((await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(403);
|
|
expect((await read(f)).permissions).not.toHaveProperty("interactions");
|
|
});
|
|
|
|
it("rejects malformed patches and pending hires without side effects", async () => {
|
|
const f = await seed(), api = app(board(f));
|
|
for (const input of [{}, { canMentionAgents: "false" }, { canMentionAgents: false, unexpected: false }]) {
|
|
expect((await request(api).patch(path(f)).send(input)).status).toBe(400);
|
|
}
|
|
await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId));
|
|
expect((await request(api).patch(path(f)).send({ canMentionAgents: false })).status).toBe(409);
|
|
expect((await read(f)).permissions).not.toHaveProperty("interactions");
|
|
});
|
|
|
|
it("serializes independent policy patches and preserves restrictions through legacy permission saves", async () => {
|
|
const f = await seed(), api = app(board(f));
|
|
const responses = await Promise.all([
|
|
request(api).patch(path(f)).send({ canMentionAgents: false }),
|
|
request(api).patch(path(f)).send({ canAssignTasksToAgents: false }),
|
|
agentService(db).updatePermissions(f.agentId, { canCreateAgents: false }),
|
|
]);
|
|
expect(responses.slice(0, 2).map(result => result && "status" in result ? result.status : null)).toEqual([200, 200]);
|
|
const saved = await read(f);
|
|
expect(saved.permissions).toMatchObject({ canCreateAgents: false, unrelated: "preserved",
|
|
interactions: { canMentionAgents: false, canAssignTasksToAgents: false } });
|
|
await expect(agentService(db).updatePermissions(f.agentId, { canCreateAgents: false, interactions: {} }))
|
|
.rejects.toMatchObject({ status: 403 });
|
|
await agentService(db).update(f.agentId, { permissions: { canCreateSkills: true } }, { recordRevision: { createdByUserId: f.userId } });
|
|
expect((await read(f)).permissions.interactions).toEqual(saved.permissions.interactions);
|
|
expect(preserveAgentInteractionPermissions(saved.permissions, {})).toHaveProperty("interactions", saved.permissions.interactions);
|
|
expect(() => assertNoInitialAgentInteractionPermissions({ interactions: {} })).toThrow(/interaction-permissions/);
|
|
});
|
|
|
|
it("uses the real CLI against HTTP and PostgreSQL for inspect, all eleven settings, and reset", async () => {
|
|
const f = await seed(), api = app(board(f));
|
|
const server = api.listen(0, "127.0.0.1");
|
|
await new Promise<void>(resolve => server.once("listening", resolve));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("HTTP fixture unavailable");
|
|
const repo = fileURLToPath(new URL("../../../", import.meta.url));
|
|
const cli = async (command: string, payload?: Record<string, boolean | null>) => {
|
|
const result = await execute(process.execPath, ["--import", `${repo}cli/node_modules/tsx/dist/loader.mjs`, `${repo}cli/src/index.ts`,
|
|
"agent", command, f.agentId, "--api-base", `http://127.0.0.1:${address.port}`, "--api-key", "fixture-board-token", "--json",
|
|
...(payload ? ["--payload-json", JSON.stringify(payload)] : [])], {
|
|
cwd: repo, env: { ...process.env, PAPERCLIP_TELEMETRY_DISABLED: "1" }, timeout: 60000,
|
|
});
|
|
return JSON.parse(result.stdout);
|
|
};
|
|
try {
|
|
expect((await cli("interaction-permissions")).overrides).toEqual({});
|
|
expect((await cli("interaction-permissions:update", { canReceiveAssignmentsFromAgents: false })).overrides)
|
|
.toEqual({ canReceiveAssignmentsFromAgents: false });
|
|
const allDisabled = Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, false]));
|
|
expect((await cli("interaction-permissions:update", allDisabled)).effective).toEqual(allDisabled);
|
|
expect((await read(f)).permissions.interactions).toEqual(allDisabled);
|
|
const peer = await db.select().from(agents).where(eq(agents.id, f.peerId));
|
|
expect(peer[0]!.permissions).not.toHaveProperty("interactions");
|
|
expect((await cli("interaction-permissions:update", Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, null])))).overrides).toEqual({});
|
|
expect((await read(f)).permissions).not.toHaveProperty("interactions");
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
}
|
|
}, 120000);
|
|
|
|
it("preserves historical mentions on a full configuration save but rejects changed mention-bearing text", async () => {
|
|
const f = await seed();
|
|
const mention = `Existing [@Peer](agent://${f.peerId}) context`;
|
|
await db.update(agents).set({ capabilities: mention }).where(eq(agents.id, f.agentId));
|
|
expect((await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(200);
|
|
const options = { recordRevision: { createdByAgentId: f.agentId, source: "interaction-test" } };
|
|
await agentService(db).update(f.agentId, { title: "Updated title", capabilities: mention }, options);
|
|
expect((await read(f)).title).toBe("Updated title");
|
|
await expect(agentService(db).update(f.agentId, { title: "Rejected title", capabilities: `${mention} changed` }, options))
|
|
.rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } });
|
|
expect((await read(f)).title).toBe("Updated title");
|
|
const peer = { ...f, agentId: f.peerId };
|
|
expect((await request(app(board(f))).patch(path(peer)).send({ canReceiveConfigurationChangesFromAgents: false })).status).toBe(200);
|
|
await expect(agentService(db).update(f.peerId, { title: "Prohibited peer change" }, options))
|
|
.rejects.toMatchObject({ status: 403, details: { permission: "canReceiveConfigurationChangesFromAgents" } });
|
|
expect((await read(peer)).title).toBeNull();
|
|
});
|
|
it("blocks protected peer tool assignments without affecting shared profile changes", async () => {
|
|
const f = await seed();
|
|
const target = { ...f, agentId: f.peerId };
|
|
expect((await request(app(board(f))).patch(path(target)).send({ canReceiveConfigurationChangesFromAgents: false })).status).toBe(200);
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId: f.companyId, profileKey: "interaction-tools", name: "Interaction tools" }).returning();
|
|
const [application] = await db.insert(toolApplications).values({ companyId: f.companyId, name: "Tool fixture", type: "mcp_http" }).returning();
|
|
const [connection] = await db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
|
|
name: "Tool connection", uid: randomUUID(), transport: "mcp_remote", authKind: "none", enabled: true, status: "active" }).returning();
|
|
const service = toolAccessService(db);
|
|
const source = { actorType: "agent" as const, actorId: f.agentId };
|
|
const denied = { status: 403, details: { permission: "canReceiveConfigurationChangesFromAgents" } };
|
|
await expect(service.bindProfile(profile.id, { targetType: "agent", targetId: f.peerId, priority: 100 }, source)).rejects.toMatchObject(denied);
|
|
await expect(service.putConnectionInstalls(connection.id, { installs: [{ targetType: "agent", targetId: f.peerId }] }, source)).rejects.toMatchObject(denied);
|
|
await expect(createToolGatewayService(db).createNamedGateway({ companyId: f.companyId,
|
|
body: { name: "Protected gateway", profileId: profile.id, agentId: f.peerId }, actor: { agentId: f.agentId } })).rejects.toMatchObject(denied);
|
|
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.companyId, f.companyId))).toHaveLength(0);
|
|
expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, f.companyId))).toHaveLength(0);
|
|
expect(await db.select().from(toolMcpGateways).where(eq(toolMcpGateways.companyId, f.companyId))).toHaveLength(0);
|
|
await service.bindProfile(profile.id, { targetType: "agent", targetId: f.peerId, priority: 100 });
|
|
await withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.agentId }, async () => {
|
|
await service.updateProfile(profile.id, { description: "Shared profile remains editable" });
|
|
await expect(service.unbindProfile(profile.id, { targetType: "agent", targetId: f.peerId })).rejects.toMatchObject(denied);
|
|
const gateway = await createToolGatewayService(db).createNamedGateway({ companyId: f.companyId,
|
|
body: { name: "Own gateway", profileId: profile.id, agentId: f.agentId } });
|
|
await expect(createToolGatewayService(db).updateNamedGateway({ companyId: f.companyId, gatewayId: gateway.id,
|
|
body: { agentId: f.peerId } })).rejects.toMatchObject(denied);
|
|
});
|
|
expect((await service.getProfile(profile.id, f.companyId)).description).toBe("Shared profile remains editable");
|
|
});
|
|
|
|
it("preserves importer identity and rolls back forbidden imported work while retaining historical comments", async () => {
|
|
const f = await seed();
|
|
await db.update(agents).set({ name: "Peer" }).where(eq(agents.id, f.peerId));
|
|
const target = { ...f, agentId: f.peerId };
|
|
const files: Record<string, string> = {
|
|
"COMPANY.md": '---\nschema: "agentcompanies/v1"\nname: "Imported"\n---\n',
|
|
"tasks/work/TASK.md": '---\nname: "Imported task"\nassignee: "peer"\n---\nInitial instructions.',
|
|
"tasks/work/doc.md": "Initial document.",
|
|
".paperclip.yaml": 'schemaVersion: 7\ntasks:\n work:\n status: todo\n comments:\n - body: "Historical comment"\n authorType: system\n documents:\n - key: spec\n title: Specification\n format: markdown\n path: tasks/work/doc.md\n',
|
|
};
|
|
const input = { source: { type: "inline" as const, files }, include: { company: false, agents: false, projects: false, issues: true },
|
|
target: { mode: "existing_company" as const, companyId: f.companyId }, collisionStrategy: "skip" as const };
|
|
const options = { mode: "agent_safe" as const, sourceCompanyId: f.companyId, actorAgentId: f.agentId, actorAgentCompanyId: f.companyId };
|
|
await request(app(board(f))).patch(path(target)).send({ canReceiveAssignmentsFromAgents: false });
|
|
await expect(companyPortabilityService(db).importBundle(input, null, options))
|
|
.rejects.toMatchObject({ status: 403, details: { permission: "canReceiveAssignmentsFromAgents" } });
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(0);
|
|
await request(app(board(f))).patch(path(target)).send({ canReceiveAssignmentsFromAgents: null, canReceiveCommentsFromAgents: false });
|
|
await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false, canCommentOnOtherTasks: false });
|
|
files["tasks/work/doc.md"] = `[@Peer](agent://${f.peerId})`;
|
|
await expect(companyPortabilityService(db).importBundle(input, null, options))
|
|
.rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } });
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(0);
|
|
files["tasks/work/doc.md"] = "Initial document.";
|
|
await companyPortabilityService(db).importBundle(input, null, options);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toEqual([
|
|
expect.objectContaining({ assigneeAgentId: f.peerId }),
|
|
]);
|
|
expect(await db.select().from(issueComments).where(eq(issueComments.companyId, f.companyId))).toEqual([
|
|
expect.objectContaining({ body: "Historical comment", authorType: "system", authorAgentId: null }),
|
|
]);
|
|
files["tasks/work/TASK.md"] = '---\nname: "Unassigned imported task"\n---\nInitial instructions.';
|
|
files["agents/new-peer/AGENTS.md"] = '---\nname: "New Peer"\nrole: engineer\n---\nInitial instructions.';
|
|
files[".paperclip.yaml"] += 'agents:\n new-peer:\n adapterType: claude_local\n';
|
|
const created = await companyPortabilityService(db).importBundle({ ...input, adapterOverrides: { "new-peer": { adapterType: "claude_local" } }, include: { ...input.include, agents: true },
|
|
target: { mode: "new_company", newCompanyName: "Agent imported company" } }, null, options);
|
|
expect(created.warnings.filter(warning => warning.includes("materialize"))).toEqual([]);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, created.company.id))).toHaveLength(1);
|
|
expect(await db.select().from(agents).where(eq(agents.companyId, created.company.id))).toEqual([
|
|
expect.objectContaining({ name: "New Peer" }),
|
|
]);
|
|
await expect(createAgentLifecycle(db).requestHire(f.companyId, { name: "Mentioned hire", adapterType: "process", capabilities: `[@Peer](agent://${f.peerId})` },
|
|
{ createdByAgentId: f.agentId })).rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } });
|
|
expect(await db.select().from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.name, "Mentioned hire")))).toHaveLength(0);
|
|
});
|
|
|
|
});
|