fix: include run policy in low-trust self checks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 18:06:39 +00:00
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);
+42 -8
View File
@@ -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}`);