From 028a8942a2bf6abce087e545a605e287bd345436 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 4 Jun 2026 20:09:01 +0000 Subject: [PATCH] Add low-trust HTTP regression suite --- .../low-trust-red-team-routes.test.ts | 563 ++++++++++++++++++ server/src/routes/agents.ts | 47 ++ server/src/routes/issues.ts | 68 +++ 3 files changed, 678 insertions(+) create mode 100644 server/src/__tests__/low-trust-red-team-routes.test.ts diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts new file mode 100644 index 00000000000..14b3f4ff8a3 --- /dev/null +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -0,0 +1,563 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agentWakeupRequests, + agents, + approvals, + companies, + createDb, + documentRevisions, + documents, + heartbeatRuns, + issueApprovals, + issueComments, + issueDocuments, + issueRelations, + issues, + issueThreadInteractions, + issueWorkProducts, + projects, +} from "@paperclipai/db"; +import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { agentRoutes } from "../routes/agents.js"; +import { issueRoutes } from "../routes/issues.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres low-trust route tests on this host: ${ + embeddedPostgresSupport.reason ?? "unsupported environment" + }`, + ); +} + +type Db = ReturnType; +type Fixture = Awaited>; + +function expectNoCanary(value: unknown, ...markers: string[]) { + const serialized = JSON.stringify(value); + for (const marker of markers) expect(serialized).not.toContain(marker); +} + +function agentActor(fixture: Fixture, agentId = fixture.agents.lowTrust.id): Express.Request["actor"] { + return { + type: "agent", + agentId, + companyId: fixture.company.id, + runId: agentId === fixture.agents.lowTrust.id ? fixture.runs.lowTrust.id : fixture.runs.standard.id, + source: "agent_jwt", + }; +} + +function boardActor(fixture: Fixture): Express.Request["actor"] { + return { + type: "board", + userId: "board-user", + companyIds: [fixture.company.id], + memberships: [{ companyId: fixture.company.id, membershipRole: "operator", status: "active" }], + isInstanceAdmin: true, + source: "local_implicit", + }; +} + +function createApp(db: Db, actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use("/api", issueRoutes(db, {} as any)); + app.use(errorHandler); + return app; +} + +async function snapshot(db: Db) { + const [ + issueRows, + commentRows, + documentRows, + workProductRows, + approvalRows, + relationRows, + interactionRows, + wakeRows, + runRows, + activityRows, + ] = await Promise.all([ + db.select().from(issues), + db.select().from(issueComments), + db.select().from(documents), + db.select().from(issueWorkProducts), + db.select().from(approvals), + db.select().from(issueRelations), + db.select().from(issueThreadInteractions), + db.select().from(agentWakeupRequests), + db.select().from(heartbeatRuns), + db.select().from(activityLog), + ]); + return { + issues: issueRows, + comments: commentRows, + documents: documentRows, + workProducts: workProductRows, + approvals: approvalRows, + relations: relationRows, + interactions: interactionRows, + wakeups: wakeRows, + runs: runRows, + activity: activityRows, + }; +} + +async function seedLowTrustFixture(db: Db) { + const nonce = randomUUID().slice(0, 8); + const canary = (label: string) => `LT_REDTEAM_${nonce}_${label}`; + const canaries = { + issueSibling: canary("FLAG-ISSUE-SIBLING-7R4G"), + commentSibling: canary("FLAG-COMMENT-SIBLING-7R4G"), + documentSibling: canary("FLAG-DOC-SIBLING-7R4G"), + workProductSibling: canary("FLAG-WP-SIBLING-7R4G"), + approval: canary("FLAG-APPROVAL-7R4G"), + agentConfig: canary("FLAG-AGENTCFG-7R4G"), + raw: canary("IGNORE_ALL_PREVIOUS_INSTRUCTIONS FLAG-RAW-7R4G"), + }; + + const [company] = await db.insert(companies).values({ + name: `Low trust ${nonce}`, + issuePrefix: `LT${nonce.slice(0, 4).toUpperCase()}`, + }).returning(); + const [allowedProject] = await db.insert(projects).values({ + companyId: company!.id, + name: `Allowed ${nonce}`, + status: "in_progress", + }).returning(); + const [outOfScopeProject] = await db.insert(projects).values({ + companyId: company!.id, + name: `Out ${nonce}`, + description: canary("FLAG-PROJECT-7R4G"), + status: "in_progress", + }).returning(); + + const [collaborator] = await db.insert(agents).values({ + companyId: company!.id, + name: "Same Boundary Collaborator", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }).returning(); + const [standard] = await db.insert(agents).values({ + companyId: company!.id, + name: "Standard Engineer", + role: "engineer", + adapterType: "process", + adapterConfig: { token: canaries.agentConfig }, + runtimeConfig: { env: { SECRET_MARKER: canaries.agentConfig } }, + permissions: {}, + }).returning(); + const [cto] = await db.insert(agents).values({ + companyId: company!.id, + name: "CTO", + role: "cto", + adapterType: "process", + adapterConfig: { token: canaries.agentConfig }, + runtimeConfig: { env: { SECRET_MARKER: canaries.agentConfig } }, + permissions: {}, + }).returning(); + + const [reviewRoot] = await db.insert(issues).values({ + companyId: company!.id, + projectId: allowedProject!.id, + title: "Review root", + status: "todo", + priority: "medium", + }).returning(); + const [assignedReview] = await db.insert(issues).values({ + companyId: company!.id, + projectId: allowedProject!.id, + parentId: reviewRoot!.id, + title: "Assigned low-trust review", + status: "in_progress", + priority: "medium", + }).returning(); + const [sameBoundaryChild] = await db.insert(issues).values({ + companyId: company!.id, + projectId: allowedProject!.id, + parentId: reviewRoot!.id, + title: "Same boundary child", + status: "todo", + priority: "medium", + }).returning(); + const [siblingOutOfScope] = await db.insert(issues).values({ + companyId: company!.id, + projectId: outOfScopeProject!.id, + title: `Sibling ${canaries.issueSibling}`, + description: canaries.issueSibling, + status: "todo", + priority: "medium", + }).returning(); + + const [lowTrust] = await db.insert(agents).values({ + companyId: company!.id, + name: "Low Trust Reviewer", + role: "engineer", + adapterType: "process", + adapterConfig: { token: canaries.agentConfig }, + runtimeConfig: { env: { SECRET_MARKER: canaries.agentConfig } }, + permissions: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: company!.id, + projectIds: [allowedProject!.id], + rootIssueId: reviewRoot!.id, + issueIds: [reviewRoot!.id, assignedReview!.id, sameBoundaryChild!.id], + allowedAgentIds: [collaborator!.id], + }, + }, + }, + }).returning(); + + await db.update(issues).set({ assigneeAgentId: lowTrust!.id }).where(eq(issues.id, assignedReview!.id)); + assignedReview!.assigneeAgentId = lowTrust!.id; + + const executionPolicy = { + authorizationPolicy: { + trustBoundary: (lowTrust!.permissions as any).authorizationPolicy.trustBoundary, + }, + }; + const [lowTrustRun] = await db.insert(heartbeatRuns).values({ + companyId: company!.id, + agentId: lowTrust!.id, + status: "running", + contextSnapshot: { + issueId: assignedReview!.id, + executionPolicy, + }, + }).returning(); + const [standardRun] = await db.insert(heartbeatRuns).values({ + companyId: company!.id, + agentId: standard!.id, + status: "running", + contextSnapshot: { issueId: assignedReview!.id }, + }).returning(); + await db.update(issues).set({ + checkoutRunId: lowTrustRun!.id, + executionRunId: lowTrustRun!.id, + executionPolicy, + }).where(eq(issues.id, assignedReview!.id)); + assignedReview!.checkoutRunId = lowTrustRun!.id; + assignedReview!.executionRunId = lowTrustRun!.id; + assignedReview!.executionPolicy = executionPolicy; + + await db.insert(issueComments).values({ + companyId: company!.id, + issueId: siblingOutOfScope!.id, + authorAgentId: standard!.id, + authorType: "agent", + body: canaries.commentSibling, + }); + const [siblingDoc] = await db.insert(documents).values({ + companyId: company!.id, + title: "Sibling doc", + latestBody: canaries.documentSibling, + createdByAgentId: standard!.id, + updatedByAgentId: standard!.id, + }).returning(); + const [siblingRevision] = await db.insert(documentRevisions).values({ + companyId: company!.id, + documentId: siblingDoc!.id, + revisionNumber: 1, + title: "Sibling doc", + body: canaries.documentSibling, + createdByAgentId: standard!.id, + }).returning(); + await db.update(documents).set({ latestRevisionId: siblingRevision!.id }).where(eq(documents.id, siblingDoc!.id)); + await db.insert(issueDocuments).values({ + companyId: company!.id, + issueId: siblingOutOfScope!.id, + documentId: siblingDoc!.id, + key: "canary", + }); + await db.insert(issueWorkProducts).values({ + companyId: company!.id, + projectId: outOfScopeProject!.id, + issueId: siblingOutOfScope!.id, + type: "artifact", + provider: "test", + title: "Sibling work product", + status: "active", + summary: canaries.workProductSibling, + }); + const [approval] = await db.insert(approvals).values({ + companyId: company!.id, + type: "request_board_approval", + requestedByAgentId: standard!.id, + status: "pending", + payload: { summary: canaries.approval }, + }).returning(); + await db.insert(issueApprovals).values({ + companyId: company!.id, + issueId: assignedReview!.id, + approvalId: approval!.id, + linkedByAgentId: standard!.id, + }); + + return { + company: company!, + agents: { lowTrust: lowTrust!, standard: standard!, collaborator: collaborator!, cto: cto! }, + projects: { allowed: allowedProject!, outOfScope: outOfScopeProject! }, + issues: { reviewRoot: reviewRoot!, assignedReview: assignedReview!, sameBoundaryChild: sameBoundaryChild!, siblingOutOfScope: siblingOutOfScope! }, + approvals: { issueLinkedCanary: approval! }, + runs: { lowTrust: lowTrustRun!, standard: standardRun! }, + canaries, + }; +} + +describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-low-trust-red-team-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueThreadInteractions); + await db.delete(issueApprovals); + await db.delete(approvals); + await db.delete(issueWorkProducts); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(activityLog); + await db.delete(agentWakeupRequests); + await db.delete(issues); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("allows bounded same-issue reads and writes while quarantining low-trust output", async () => { + const fixture = await seedLowTrustFixture(db); + const app = createApp(db, agentActor(fixture)); + + const issueRead = await request(app).get(`/api/issues/${fixture.issues.assignedReview.id}`); + expect(issueRead.status, JSON.stringify(issueRead.body)).toBe(200); + expectNoCanary(issueRead.body, fixture.canaries.issueSibling, fixture.canaries.documentSibling); + + const comment = await request(app) + .post(`/api/issues/${fixture.issues.assignedReview.id}/comments`) + .send({ body: `review note ${fixture.canaries.raw}` }); + expect(comment.status, JSON.stringify(comment.body)).toBe(201); + expect(comment.body.sourceTrust).toMatchObject({ + preset: LOW_TRUST_REVIEW_PRESET, + disposition: "quarantined", + sourceIssueId: fixture.issues.assignedReview.id, + sourceRunId: fixture.runs.lowTrust.id, + sourceAgentId: fixture.agents.lowTrust.id, + }); + + const document = await request(app) + .put(`/api/issues/${fixture.issues.assignedReview.id}/documents/review-notes`) + .send({ format: "markdown", body: `notes ${fixture.canaries.raw}` }); + expect(document.status, JSON.stringify(document.body)).toBe(201); + expect(document.body.sourceTrust).toMatchObject({ + preset: LOW_TRUST_REVIEW_PRESET, + disposition: "quarantined", + }); + + const workProduct = await request(app) + .post(`/api/issues/${fixture.issues.assignedReview.id}/work-products`) + .send({ + type: "artifact", + provider: "test", + title: "Review artifact", + status: "active", + summary: `artifact ${fixture.canaries.raw}`, + }); + expect(workProduct.status, JSON.stringify(workProduct.body)).toBe(201); + expect(workProduct.body.sourceTrust).toMatchObject({ + preset: LOW_TRUST_REVIEW_PRESET, + disposition: "quarantined", + }); + }); + + it("restricts low-trust self inspection without changing standard-agent visibility", async () => { + const fixture = await seedLowTrustFixture(db); + + const lowTrustRes = await request(createApp(db, agentActor(fixture))).get("/api/agents/me"); + expect(lowTrustRes.status, JSON.stringify(lowTrustRes.body)).toBe(200); + expect(lowTrustRes.body).toMatchObject({ + id: fixture.agents.lowTrust.id, + companyId: fixture.company.id, + trustPreset: LOW_TRUST_REVIEW_PRESET, + }); + expect(lowTrustRes.body).not.toHaveProperty("adapterConfig"); + expect(lowTrustRes.body).not.toHaveProperty("runtimeConfig"); + expect(lowTrustRes.body).not.toHaveProperty("permissions"); + 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"); + expect(standardRes.status, JSON.stringify(standardRes.body)).toBe(200); + expect(JSON.stringify(standardRes.body)).toContain(fixture.canaries.agentConfig); + }); + + it("denies out-of-bound and control-plane attempts without leaking canaries or creating durable side effects", async () => { + const fixture = await seedLowTrustFixture(db); + const app = createApp(db, agentActor(fixture)); + const forbiddenMarkers = Object.values(fixture.canaries); + + const attempts = [ + { + id: "LT-02", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}`), + }, + { + id: "LT-08", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/documents/canary`), + }, + { + id: "LT-15/16", + req: () => request(app).get(`/api/agents/${fixture.agents.cto.id}`), + }, + { + id: "LT-19", + req: () => request(app).get(`/api/issues/${fixture.issues.assignedReview.id}/approvals`), + }, + { + id: "LT-26 child", + req: () => request(app) + .post(`/api/issues/${fixture.issues.assignedReview.id}/children`) + .send({ title: `child ${fixture.canaries.issueSibling}` }), + }, + { + id: "LT-26 company issue", + req: () => request(app) + .post(`/api/companies/${fixture.company.id}/issues`) + .send({ title: `child ${fixture.canaries.issueSibling}`, parentId: fixture.issues.assignedReview.id }), + }, + { + id: "LT-26 interaction", + req: () => request(app) + .post(`/api/issues/${fixture.issues.assignedReview.id}/interactions`) + .send({ + kind: "ask_user_questions", + title: "exfil", + payload: { + version: 1, + questions: [{ + id: "q1", + prompt: fixture.canaries.approval, + selectionMode: "single", + options: [ + { id: "a", label: "A", description: "A" }, + { id: "b", label: "B", description: "B" }, + ], + }], + }, + }), + }, + { + id: "LT-06 resume", + req: () => request(app) + .post(`/api/issues/${fixture.issues.assignedReview.id}/comments`) + .send({ body: "resume please", resume: true }), + }, + { + id: "LT-06 blocker mutation", + req: () => request(app) + .patch(`/api/issues/${fixture.issues.assignedReview.id}`) + .send({ comment: "add blocker", blockedByIssueIds: [fixture.issues.siblingOutOfScope.id] }), + }, + ]; + + for (const attempt of attempts) { + const before = await snapshot(db); + const res = await attempt.req(); + expect(res.status, `${attempt.id}: ${JSON.stringify(res.body)}`).toBe(403); + expectNoCanary(res.body, ...forbiddenMarkers); + const after = await snapshot(db); + expect(after.issues.length, attempt.id).toBe(before.issues.length); + expect(after.comments.length, attempt.id).toBe(before.comments.length); + expect(after.documents.length, attempt.id).toBe(before.documents.length); + expect(after.workProducts.length, attempt.id).toBe(before.workProducts.length); + expect(after.approvals.length, attempt.id).toBe(before.approvals.length); + expect(after.relations.length, attempt.id).toBe(before.relations.length); + expect(after.interactions.length, attempt.id).toBe(before.interactions.length); + expect(after.wakeups.length, attempt.id).toBe(before.wakeups.length); + expect(after.runs.length, attempt.id).toBe(before.runs.length); + } + }); + + it("keeps board positive controls for issue-linked approvals and sanitized promotion", async () => { + const fixture = await seedLowTrustFixture(db); + const app = createApp(db, boardActor(fixture)); + + const approvalsRes = await request(app).get(`/api/issues/${fixture.issues.assignedReview.id}/approvals`); + expect(approvalsRes.status, JSON.stringify(approvalsRes.body)).toBe(200); + expect(JSON.stringify(approvalsRes.body)).toContain(fixture.canaries.approval); + + const [rawProduct] = await db.insert(issueWorkProducts).values({ + companyId: fixture.company.id, + projectId: fixture.projects.allowed.id, + issueId: fixture.issues.assignedReview.id, + type: "artifact", + provider: "test", + title: "Quarantined raw artifact", + status: "active", + summary: fixture.canaries.raw, + 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 promotion = await request(app) + .post(`/api/issues/${fixture.issues.assignedReview.id}/low-trust/promotions`) + .send({ + sourceArtifactKind: "work_product", + sourceArtifactId: rawProduct!.id, + title: "Sanitized finding", + summary: "Sanitized summary without raw instructions.", + }); + expect(promotion.status, JSON.stringify(promotion.body)).toBe(201); + expect(promotion.body.sourceTrust).toMatchObject({ + preset: LOW_TRUST_REVIEW_PRESET, + disposition: "promoted", + promotedFrom: { + artifactKind: "work_product", + artifactId: rawProduct!.id, + issueId: fixture.issues.assignedReview.id, + }, + }); + expectNoCanary(promotion.body, fixture.canaries.raw); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b32663169b9..81e496c6c50 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -25,6 +25,7 @@ import { wakeAgentSchema, updateAgentSchema, supportedEnvironmentDriversForAdapter, + LOW_TRUST_REVIEW_PRESET, } from "@paperclipai/shared"; import { readPaperclipSkillSyncPreference, @@ -99,6 +100,7 @@ import { import { getTelemetryClient } from "../telemetry.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { recoveryService } from "../services/recovery/service.js"; +import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; @@ -563,6 +565,46 @@ export function agentRoutes( }; } + async function resolveAgentSelfTrustPreset(req: Request, agent: NonNullable>>) { + const run = req.actor.type === "agent" && 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, agent.companyId))) + .then((rows) => rows[0] ?? null) + : null; + const runContext = run?.agentId === agent.id && run.contextSnapshot && typeof run.contextSnapshot === "object" + ? run.contextSnapshot as Record + : null; + const runExecutionPolicy = runContext?.executionPolicy && typeof runContext.executionPolicy === "object" + ? runContext.executionPolicy as Record + : null; + + return resolveCoreTrustPreset({ + companyId: agent.companyId, + agent, + project: null, + issue: null, + run: runExecutionPolicy ? { companyId: agent.companyId, executionPolicy: runExecutionPolicy } : null, + }); + } + + function buildLowTrustSelfView(agent: NonNullable>>) { + return { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + role: agent.role, + title: agent.title, + status: agent.status, + trustPreset: LOW_TRUST_REVIEW_PRESET, + }; + } + async function applyDefaultAgentTaskAssignGrant( companyId: string, agentId: string, @@ -1756,6 +1798,11 @@ export function agentRoutes( res.status(404).json({ error: "Agent not found" }); return; } + const trustPreset = await resolveAgentSelfTrustPreset(req, agent); + if (trustPreset.kind !== "standard") { + res.json(buildLowTrustSelfView(agent)); + return; + } res.json(await buildAgentDetail(agent)); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index c2f24e0f179..7e4d7e648bf 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -131,6 +131,7 @@ import { resolveActorSourceTrustForIssue, sanitizeQuarantinedCommentForHigherTrust, } from "../services/source-trust.js"; +import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; const updateIssueRouteSchema = updateIssueSchema.extend({ @@ -979,6 +980,59 @@ export function issueRoutes( return resolveActorSourceTrustForIssue({ db, issue, actor }); } + async function actorIsLowTrustReview( + req: Request, + companyId: string, + issue?: { companyId: string; executionPolicy?: unknown } | null, + ) { + if (req.actor.type !== "agent" || !req.actor.agentId) return false; + const [agent, run] = await Promise.all([ + agentsSvc.getById(req.actor.agentId), + req.actor.runId + ? db + .select({ + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, req.actor.runId), eq(heartbeatRuns.companyId, companyId))) + .then((rows) => rows[0] ?? null) + : Promise.resolve(null), + ]); + if (!agent || agent.companyId !== companyId) return false; + const runContext = run?.agentId === agent.id && run.contextSnapshot && typeof run.contextSnapshot === "object" + ? run.contextSnapshot as Record + : null; + const runExecutionPolicy = runContext?.executionPolicy && typeof runContext.executionPolicy === "object" + ? runContext.executionPolicy as Record + : null; + const resolution = resolveCoreTrustPreset({ + companyId, + agent, + project: null, + issue: issue + ? { + companyId: issue.companyId, + executionPolicy: issue.executionPolicy, + } + : null, + run: runExecutionPolicy ? { companyId, executionPolicy: runExecutionPolicy } : null, + }); + return resolution.kind !== "standard"; + } + + async function assertLowTrustControlPlaneDenied( + req: Request, + res: Response, + companyId: string, + issue?: { companyId: string; executionPolicy?: unknown } | null, + ) { + if (!(await actorIsLowTrustReview(req, companyId, issue))) return false; + res.status(403).json({ error: "Low-trust actors cannot use this control-plane surface" }); + return true; + } + async function shouldRedactLowTrustForHeartbeatContext( issue: { id: string; companyId: string; projectId?: string | null; executionPolicy?: unknown }, actor: ReturnType, @@ -1823,6 +1877,8 @@ export function issueRoutes( res: Response, issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null }, ) { + if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false; + if (issue.status === "cancelled") { res.status(409).json({ error: "Cancelled issues must be restored through the dedicated restore flow", @@ -3896,6 +3952,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return; const approvals = await issueApprovalsSvc.listApprovalsForIssue(id); res.json(approvals); }); @@ -3966,6 +4023,7 @@ export function issueRoutes( router.post("/companies/:companyId/issues", applyCreateIssueStatusDefault, validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + if (await assertLowTrustControlPlaneDenied(req, res, companyId, null)) return; assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (req.actor.type === "agent" && !req.body.parentId) { const companyScopeDecision = await access.decide({ @@ -4100,6 +4158,7 @@ export function issueRoutes( } assertCompanyAccess(req, parent.companyId); if (!(await assertIssueReadAllowed(req, res, parent))) return; + if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return; assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { @@ -4436,6 +4495,14 @@ export function issueRoutes( res.status(400).json({ error: "Follow-up intent requires a comment" }); return; } + if ( + (reopenRequested === true || + resumeRequested === true || + Array.isArray(req.body.blockedByIssueIds)) && + await assertLowTrustControlPlaneDenied(req, res, existing.companyId, existing) + ) { + return; + } if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, existing))) return; if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") { if (!(await assertExplicitResumeIntentAllowed(req, res, existing))) return; @@ -5651,6 +5718,7 @@ export function issueRoutes( assertCompanyAccess(req, issue.companyId); if (req.actor.type === "agent") { if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return; } else { assertBoard(req); }