mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
fix: include run policy in low-trust self checks
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4ad567c728
commit
711a29338d
4 files changed
+191
-22
No files matched your search
@@ -588,9 +588,46 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
|
||||
expect(lowTrustRes.body).not.toHaveProperty("access");
|
||||
expectNoCanary(lowTrustRes.body, fixture.canaries.agentConfig);
|
||||
|
||||
const standardRes = await request(createApp(db, agentActor(fixture, fixture.agents.standard.id))).get("/api/agents/me");
|
||||
const standardActor = agentActor(fixture, fixture.agents.standard.id);
|
||||
const standardRes = await request(createApp(db, { ...standardActor, runId: null })).get("/api/agents/me");
|
||||
expect(standardRes.status, JSON.stringify(standardRes.body)).toBe(200);
|
||||
expect(JSON.stringify(standardRes.body)).toContain(fixture.canaries.agentConfig);
|
||||
|
||||
const issueScopedLowTrustRes = await request(createApp(db, standardActor)).get("/api/agents/me");
|
||||
expect(issueScopedLowTrustRes.status, JSON.stringify(issueScopedLowTrustRes.body)).toBe(200);
|
||||
expect(issueScopedLowTrustRes.body).toMatchObject({
|
||||
id: fixture.agents.standard.id,
|
||||
companyId: fixture.company.id,
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
});
|
||||
expect(issueScopedLowTrustRes.body).not.toHaveProperty("adapterConfig");
|
||||
expect(issueScopedLowTrustRes.body).not.toHaveProperty("runtimeConfig");
|
||||
expectNoCanary(issueScopedLowTrustRes.body, fixture.canaries.agentConfig);
|
||||
|
||||
await db.update(issues).set({ executionPolicy: null }).where(eq(issues.id, fixture.issues.assignedReview.id));
|
||||
|
||||
await db.update(projects).set({
|
||||
executionWorkspacePolicy: {
|
||||
authorizationPolicy: {
|
||||
trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET,
|
||||
companyId: fixture.company.id,
|
||||
projectIds: [fixture.projects.allowed.id],
|
||||
},
|
||||
},
|
||||
},
|
||||
}).where(eq(projects.id, fixture.projects.allowed.id));
|
||||
|
||||
const projectScopedLowTrustRes = await request(createApp(db, agentActor(fixture, fixture.agents.standard.id))).get("/api/agents/me");
|
||||
expect(projectScopedLowTrustRes.status, JSON.stringify(projectScopedLowTrustRes.body)).toBe(200);
|
||||
expect(projectScopedLowTrustRes.body).toMatchObject({
|
||||
id: fixture.agents.standard.id,
|
||||
companyId: fixture.company.id,
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
});
|
||||
expect(projectScopedLowTrustRes.body).not.toHaveProperty("adapterConfig");
|
||||
expect(projectScopedLowTrustRes.body).not.toHaveProperty("runtimeConfig");
|
||||
expectNoCanary(projectScopedLowTrustRes.body, fixture.canaries.agentConfig);
|
||||
});
|
||||
|
||||
it("denies out-of-bound and control-plane attempts without leaking canaries or creating durable side effects", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
@@ -43,6 +44,7 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => {
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
@@ -239,6 +241,59 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects runtime service mutations when only the run policy is low-trust without runtime.manage", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
|
||||
const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" });
|
||||
const issueId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Run-scoped low-trust workspace",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: ceoAgentId,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: ceoAgentId,
|
||||
status: "running",
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
executionPolicy: {
|
||||
authorizationPolicy: {
|
||||
trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET,
|
||||
companyId,
|
||||
projectIds: [projectId],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: ceoAgentId,
|
||||
companyId,
|
||||
runId,
|
||||
source: "agent_key",
|
||||
},
|
||||
} as any, {
|
||||
companyId,
|
||||
projectWorkspaceId,
|
||||
})).rejects.toMatchObject({
|
||||
status: 403,
|
||||
message: "Low-trust runs cannot manage workspace runtime services unless the boundary grants runtime.manage",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows agents with a non-terminal assigned issue in the target project workspace", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db";
|
||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentSkillSyncSchema,
|
||||
@@ -118,6 +118,20 @@ function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) {
|
||||
return Math.min(max, Math.trunc(parsed));
|
||||
}
|
||||
|
||||
function readObject(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function readRunIssueId(context: Record<string, unknown> | null) {
|
||||
const directIssueId = context?.issueId;
|
||||
if (typeof directIssueId === "string" && isUuidLike(directIssueId)) return directIssueId;
|
||||
const paperclipIssue = readObject(context?.paperclipIssue);
|
||||
const nestedIssueId = paperclipIssue?.id;
|
||||
return typeof nestedIssueId === "string" && isUuidLike(nestedIssueId) ? nestedIssueId : null;
|
||||
}
|
||||
|
||||
export function agentRoutes(
|
||||
db: Db,
|
||||
options: { pluginWorkerManager?: PluginWorkerManager } = {},
|
||||
@@ -577,18 +591,38 @@ export function agentRoutes(
|
||||
.where(and(eq(heartbeatRuns.id, req.actor.runId), eq(heartbeatRuns.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const runContext = run?.agentId === agent.id && run.contextSnapshot && typeof run.contextSnapshot === "object"
|
||||
? run.contextSnapshot as Record<string, unknown>
|
||||
: null;
|
||||
const runExecutionPolicy = runContext?.executionPolicy && typeof runContext.executionPolicy === "object"
|
||||
? runContext.executionPolicy as Record<string, unknown>
|
||||
const runContext = run?.agentId === agent.id ? readObject(run.contextSnapshot) : null;
|
||||
const runExecutionPolicy = readObject(runContext?.executionPolicy);
|
||||
const runIssueId = readRunIssueId(runContext);
|
||||
const runScopedIssue = runIssueId
|
||||
? await db
|
||||
.select({
|
||||
companyId: issuesTable.companyId,
|
||||
projectId: issuesTable.projectId,
|
||||
executionPolicy: issuesTable.executionPolicy,
|
||||
projectExecutionWorkspacePolicy: projectsTable.executionWorkspacePolicy,
|
||||
})
|
||||
.from(issuesTable)
|
||||
.leftJoin(projectsTable, and(eq(projectsTable.id, issuesTable.projectId), eq(projectsTable.companyId, issuesTable.companyId)))
|
||||
.where(and(eq(issuesTable.id, runIssueId), eq(issuesTable.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
||||
return resolveCoreTrustPreset({
|
||||
companyId: agent.companyId,
|
||||
agent,
|
||||
project: null,
|
||||
issue: null,
|
||||
project: runScopedIssue?.projectId
|
||||
? {
|
||||
companyId: runScopedIssue.companyId,
|
||||
executionWorkspacePolicy: runScopedIssue.projectExecutionWorkspacePolicy,
|
||||
}
|
||||
: null,
|
||||
issue: runScopedIssue
|
||||
? {
|
||||
companyId: runScopedIssue.companyId,
|
||||
executionPolicy: runScopedIssue.executionPolicy,
|
||||
}
|
||||
: null,
|
||||
run: runExecutionPolicy ? { companyId: agent.companyId, executionPolicy: runExecutionPolicy } : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull, ne, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import type { Request } from "express";
|
||||
@@ -16,6 +16,20 @@ const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [
|
||||
"blocked",
|
||||
];
|
||||
|
||||
function readObject(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function readRunIssueId(context: Record<string, unknown> | null) {
|
||||
const directIssueId = context?.issueId;
|
||||
if (typeof directIssueId === "string" && directIssueId.length > 0) return directIssueId;
|
||||
const paperclipIssue = readObject(context?.paperclipIssue);
|
||||
const nestedIssueId = paperclipIssue?.id;
|
||||
return typeof nestedIssueId === "string" && nestedIssueId.length > 0 ? nestedIssueId : null;
|
||||
}
|
||||
|
||||
async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) {
|
||||
const companyAgents = await db
|
||||
.select({
|
||||
@@ -78,12 +92,32 @@ async function assertAgentCanManageRuntimeServicesForWorkspace(
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
|
||||
const actorRun = req.actor.runId
|
||||
? await db
|
||||
.select({
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(
|
||||
eq(heartbeatRuns.id, req.actor.runId),
|
||||
eq(heartbeatRuns.companyId, input.companyId),
|
||||
eq(heartbeatRuns.agentId, actorAgent.id),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const runContext = readObject(actorRun?.contextSnapshot);
|
||||
const runExecutionPolicy = readObject(runContext?.executionPolicy);
|
||||
|
||||
assertLowTrustCanManageRuntimeForActor({
|
||||
companyId: input.companyId,
|
||||
actorAgent,
|
||||
runExecutionPolicy,
|
||||
});
|
||||
|
||||
const runScopedIssue = req.actor.runId
|
||||
const runIssueId = readRunIssueId(runContext);
|
||||
const runScopedIssue = runIssueId
|
||||
? await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
@@ -92,19 +126,11 @@ async function assertAgentCanManageRuntimeServicesForWorkspace(
|
||||
executionPolicy: issues.executionPolicy,
|
||||
projectExecutionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(
|
||||
issues,
|
||||
and(
|
||||
eq(issues.companyId, heartbeatRuns.companyId),
|
||||
eq(issues.id, sql<string>`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->'paperclipIssue'->>'id')::uuid`),
|
||||
),
|
||||
)
|
||||
.from(issues)
|
||||
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, issues.companyId)))
|
||||
.where(and(
|
||||
eq(heartbeatRuns.id, req.actor.runId),
|
||||
eq(heartbeatRuns.companyId, input.companyId),
|
||||
eq(heartbeatRuns.agentId, actorAgent.id),
|
||||
eq(issues.id, runIssueId),
|
||||
eq(issues.companyId, input.companyId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
@@ -114,6 +140,7 @@ async function assertAgentCanManageRuntimeServicesForWorkspace(
|
||||
actorAgent,
|
||||
issue: runScopedIssue,
|
||||
projectExecutionWorkspacePolicy: runScopedIssue.projectExecutionWorkspacePolicy,
|
||||
runExecutionPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +180,7 @@ async function assertAgentCanManageRuntimeServicesForWorkspace(
|
||||
actorAgent,
|
||||
issue: linkedScopeIssue,
|
||||
projectExecutionWorkspacePolicy: linkedScopeIssue.projectExecutionWorkspacePolicy,
|
||||
runExecutionPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,6 +213,7 @@ async function assertAgentCanManageRuntimeServicesForWorkspace(
|
||||
actorAgent,
|
||||
issue: linkedIssue,
|
||||
projectExecutionWorkspacePolicy: linkedIssue.projectExecutionWorkspacePolicy,
|
||||
runExecutionPolicy,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -199,6 +228,7 @@ function assertLowTrustCanManageRuntimeForActor(input: {
|
||||
companyId: string;
|
||||
permissions: unknown;
|
||||
};
|
||||
runExecutionPolicy?: unknown;
|
||||
}) {
|
||||
const resolution = resolveCoreTrustPreset({
|
||||
companyId: input.companyId,
|
||||
@@ -206,6 +236,12 @@ function assertLowTrustCanManageRuntimeForActor(input: {
|
||||
companyId: input.actorAgent.companyId,
|
||||
permissions: input.actorAgent.permissions,
|
||||
},
|
||||
run: input.runExecutionPolicy
|
||||
? {
|
||||
companyId: input.companyId,
|
||||
executionPolicy: input.runExecutionPolicy,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
if (resolution.kind === "denied") {
|
||||
throw forbidden(`Low-trust runtime service access denied: ${resolution.detail}`);
|
||||
@@ -228,6 +264,7 @@ function assertLowTrustCanManageRuntimeForIssue(input: {
|
||||
executionPolicy: unknown;
|
||||
};
|
||||
projectExecutionWorkspacePolicy: unknown;
|
||||
runExecutionPolicy?: unknown;
|
||||
}) {
|
||||
const resolution = resolveCoreTrustPreset({
|
||||
companyId: input.issue.companyId,
|
||||
@@ -245,6 +282,12 @@ function assertLowTrustCanManageRuntimeForIssue(input: {
|
||||
companyId: input.issue.companyId,
|
||||
executionPolicy: input.issue.executionPolicy,
|
||||
},
|
||||
run: input.runExecutionPolicy
|
||||
? {
|
||||
companyId: input.issue.companyId,
|
||||
executionPolicy: input.runExecutionPolicy,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
if (resolution.kind === "denied") {
|
||||
throw forbidden(`Low-trust runtime service access denied: ${resolution.detail}`);
|
||||
|
||||
Reference in new issue
Block a user