diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 58652f345a8..337b68a8993 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -885,6 +885,36 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => }, }).returning(); + const [otherCompany] = await db.insert(companies).values({ + name: "Foreign low-trust source", + issuePrefix: `FGN${randomUUID().slice(0, 4).toUpperCase()}`, + }).returning(); + const [foreignIssue] = await db.insert(issues).values({ + companyId: otherCompany!.id, + parentId: fixture.issues.assignedReview.id, + title: "Foreign quarantined issue", + status: "done", + priority: "medium", + sourceTrust: { + preset: LOW_TRUST_REVIEW_PRESET, + disposition: "quarantined", + sourceIssueId: fixture.issues.assignedReview.id, + sourceRunId: fixture.runs.lowTrust.id, + sourceAgentId: fixture.agents.lowTrust.id, + }, + }).returning(); + + const rejectedPromotion = await request(app) + .post(`/api/issues/${fixture.issues.assignedReview.id}/low-trust/promotions`) + .send({ + sourceArtifactKind: "issue", + sourceArtifactId: foreignIssue!.id, + title: "Rejected foreign issue", + summary: "Should not promote across company boundaries.", + }); + expect(rejectedPromotion.status, JSON.stringify(rejectedPromotion.body)).toBe(404); + expect(rejectedPromotion.body.error).toBe("Low-trust source artifact not found"); + const promotion = await request(app) .post(`/api/issues/${fixture.issues.assignedReview.id}/low-trust/promotions`) .send({ diff --git a/server/src/__tests__/source-trust.test.ts b/server/src/__tests__/source-trust.test.ts index 463380c0965..37118bce08a 100644 --- a/server/src/__tests__/source-trust.test.ts +++ b/server/src/__tests__/source-trust.test.ts @@ -122,7 +122,7 @@ describeEmbeddedPostgres("resolveActorSourceTrustForIssue", () => { .then((rows) => rows[0]!); } - async function createAgent(companyId: string) { + async function createAgent(companyId: string, permissions: Record = {}) { return db .insert(agents) .values({ @@ -132,7 +132,7 @@ describeEmbeddedPostgres("resolveActorSourceTrustForIssue", () => { adapterType: "process", adapterConfig: {}, runtimeConfig: {}, - permissions: {}, + permissions, }) .returning() .then((rows) => rows[0]!); @@ -252,4 +252,47 @@ describeEmbeddedPostgres("resolveActorSourceTrustForIssue", () => { sourceAgentId: actorAgent.id, }); }); + + it("surfaces denied trust policy resolution instead of treating it as higher trust", async () => { + const company = await createCompany(); + const agent = await createAgent(company.id, { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: randomUUID(), + projectIds: [randomUUID()], + }, + }, + }); + const [issue] = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Denied trust policy issue", + status: "in_progress", + priority: "high", + assigneeAgentId: agent.id, + }) + .returning(); + + await expect(resolveActorSourceTrustForIssue({ + db, + issue: { + id: issue!.id, + companyId: company.id, + projectId: null, + executionPolicy: null, + }, + actor: { + actorType: "agent", + actorId: agent.id, + agentId: agent.id, + runId: null, + }, + })).rejects.toMatchObject({ + status: 403, + message: "Low-trust boundary refers to a different company.", + }); + }); }); diff --git a/server/src/__tests__/workspace-runtime-service-authz.test.ts b/server/src/__tests__/workspace-runtime-service-authz.test.ts index cd676660ae5..5f6cc4d0ea9 100644 --- a/server/src/__tests__/workspace-runtime-service-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-service-authz.test.ts @@ -200,7 +200,7 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => { }); }); - it("rejects CEO runtime service mutations for low-trust workspace issues without runtime.manage", async () => { + it("allows standard CEO runtime service mutations for low-trust workspace issues", async () => { const companyId = await seedCompany(); const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" }); @@ -235,10 +235,7 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => { } as any, { companyId, projectWorkspaceId, - })).rejects.toMatchObject({ - status: 403, - message: "Low-trust runs cannot manage workspace runtime services unless the boundary grants runtime.manage", - }); + })).resolves.toBeUndefined(); }); it("rejects runtime service mutations when only the run policy is low-trust without runtime.manage", async () => { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7cc79c73d03..90033a30049 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -130,7 +130,7 @@ import { resolveActorSourceTrustForIssue, sanitizeQuarantinedCommentForHigherTrust, } from "../services/source-trust.js"; -import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; +import { resolveCoreTrustPreset, type TrustPresetResolution } from "../services/trust-preset-resolver.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; const updateIssueRouteSchema = updateIssueSchema.extend({ @@ -979,15 +979,18 @@ export function issueRoutes( return resolveActorSourceTrustForIssue({ db, issue, actor }); } - async function actorIsLowTrustReview( - req: Request, + async function resolveAgentTrustForIssue( + input: { + agentId: string | null | undefined; + runId?: string | null; + }, companyId: string, issue?: { companyId: string; projectId?: string | null; executionPolicy?: unknown } | null, - ) { - if (req.actor.type !== "agent" || !req.actor.agentId) return false; + ): Promise { + if (!input.agentId) return null; const [agent, run] = await Promise.all([ - agentsSvc.getById(req.actor.agentId), - req.actor.runId + agentsSvc.getById(input.agentId), + input.runId ? db .select({ companyId: heartbeatRuns.companyId, @@ -995,11 +998,11 @@ export function issueRoutes( contextSnapshot: heartbeatRuns.contextSnapshot, }) .from(heartbeatRuns) - .where(and(eq(heartbeatRuns.id, req.actor.runId), eq(heartbeatRuns.companyId, companyId))) + .where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, companyId))) .then((rows) => rows[0] ?? null) : Promise.resolve(null), ]); - if (!agent || agent.companyId !== companyId) return false; + if (!agent || agent.companyId !== companyId) return null; const runContext = run?.agentId === agent.id && run.contextSnapshot && typeof run.contextSnapshot === "object" ? run.contextSnapshot as Record : null; @@ -1009,7 +1012,7 @@ export function issueRoutes( const project = issue?.projectId ? await projectsSvc.getById(issue.projectId) : null; - const resolution = resolveCoreTrustPreset({ + return resolveCoreTrustPreset({ companyId, agent, project: project?.companyId === companyId ? project : null, @@ -1021,7 +1024,19 @@ export function issueRoutes( : null, run: runExecutionPolicy ? { companyId, executionPolicy: runExecutionPolicy } : null, }); - return resolution.kind === "low_trust_review"; + } + + async function actorIsLowTrustReview( + req: Request, + companyId: string, + issue?: { companyId: string; projectId?: string | null; executionPolicy?: unknown } | null, + ) { + if (req.actor.type !== "agent") return false; + const resolution = await resolveAgentTrustForIssue({ + agentId: req.actor.agentId, + runId: req.actor.runId, + }, companyId, issue); + return resolution?.kind === "low_trust_review"; } async function assertLowTrustControlPlaneDenied( @@ -1041,6 +1056,14 @@ export function issueRoutes( ) { // Board users are trusted reviewers and intentionally receive raw quarantined output for promotion decisions. if (actor.actorType !== "agent") return false; + const resolution = await resolveAgentTrustForIssue({ + agentId: actor.agentId, + runId: actor.runId, + }, issue.companyId, issue); + if (resolution?.kind === "denied") { + throw forbidden(resolution.detail); + } + if (resolution?.kind === "low_trust_review") return false; const sourceTrust = await sourceTrustForActorWrite(issue, actor); return !sourceTrust; } @@ -1062,6 +1085,12 @@ export function issueRoutes( .where(eq(issueRows.id, input.artifactId)) .then((rows) => rows[0] ?? null); if (!row) return null; + const sourceIssue = await db + .select({ companyId: issueRows.companyId }) + .from(issueRows) + .where(eq(issueRows.id, input.issueId)) + .then((rows) => rows[0] ?? null); + if (!sourceIssue || row.companyId !== sourceIssue.companyId) return null; if (row.id !== input.issueId) { let cursor = row.parentId; let isDescendant = false; diff --git a/server/src/routes/workspace-runtime-service-authz.ts b/server/src/routes/workspace-runtime-service-authz.ts index 3258f43416c..ea620e13ad4 100644 --- a/server/src/routes/workspace-runtime-service-authz.ts +++ b/server/src/routes/workspace-runtime-service-authz.ts @@ -6,7 +6,7 @@ import { forbidden } from "../errors.js"; import { assertCompanyAccess } from "./authz.js"; import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js"; import { isLowTrustRuntimeManagementAllowed } from "../services/low-trust-runtime-containment.js"; -import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; +import { resolveCoreTrustPreset, type TrustPresetResolution } from "../services/trust-preset-resolver.js"; const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [ "backlog", @@ -110,12 +110,16 @@ async function assertAgentCanManageRuntimeServicesForWorkspace( const runContext = readObject(actorRun?.contextSnapshot); const runExecutionPolicy = readObject(runContext?.executionPolicy); - assertLowTrustCanManageRuntimeForActor({ + const actorRuntimeTrust = assertLowTrustCanManageRuntimeForActor({ companyId: input.companyId, actorAgent, runExecutionPolicy, }); + if (actorAgent.role === "ceo" && actorRuntimeTrust.kind === "standard") { + return; + } + const runIssueId = readRunIssueId(runContext); const runScopedIssue = runIssueId ? await db @@ -229,7 +233,7 @@ function assertLowTrustCanManageRuntimeForActor(input: { permissions: unknown; }; runExecutionPolicy?: unknown; -}) { +}): TrustPresetResolution { const resolution = resolveCoreTrustPreset({ companyId: input.companyId, agent: { @@ -246,8 +250,8 @@ function assertLowTrustCanManageRuntimeForActor(input: { if (resolution.kind === "denied") { throw forbidden(`Low-trust runtime service access denied: ${resolution.detail}`); } - if (resolution.kind !== "low_trust_review") return; - if (isLowTrustRuntimeManagementAllowed(resolution)) return; + if (resolution.kind !== "low_trust_review") return resolution; + if (isLowTrustRuntimeManagementAllowed(resolution)) return resolution; throw forbidden("Low-trust runs cannot manage workspace runtime services unless the boundary grants runtime.manage"); } diff --git a/server/src/services/source-trust.ts b/server/src/services/source-trust.ts index 459c52e04b9..164a951272c 100644 --- a/server/src/services/source-trust.ts +++ b/server/src/services/source-trust.ts @@ -5,6 +5,7 @@ import { LOW_TRUST_REVIEW_PRESET, type SourceTrustMetadata, } from "@paperclipai/shared"; +import { forbidden } from "../errors.js"; import { resolveCoreTrustPreset } from "./trust-preset-resolver.js"; export const LOW_TRUST_QUARANTINED_BODY = @@ -165,6 +166,9 @@ export async function resolveActorSourceTrustForIssue(input: { : null, }); + if (resolution.kind === "denied") { + throw forbidden(resolution.detail); + } if (resolution.kind !== "low_trust_review") return null; return buildLowTrustSourceTrust({ issueId: input.issue.id,