mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Add low-trust HTTP regression suite
This commit is contained in:
1 parent
af975b4d5d
commit
028a8942a2
3 files changed
+678
No files matched your search
@@ -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<typeof createDb>;
|
||||
type Fixture = Awaited<ReturnType<typeof seedLowTrustFixture>>;
|
||||
|
||||
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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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);
|
||||
});
|
||||
});
|
||||
@@ -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<Awaited<ReturnType<typeof svc.getById>>>) {
|
||||
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<string, unknown>
|
||||
: null;
|
||||
const runExecutionPolicy = runContext?.executionPolicy && typeof runContext.executionPolicy === "object"
|
||||
? runContext.executionPolicy as Record<string, unknown>
|
||||
: null;
|
||||
|
||||
return resolveCoreTrustPreset({
|
||||
companyId: agent.companyId,
|
||||
agent,
|
||||
project: null,
|
||||
issue: null,
|
||||
run: runExecutionPolicy ? { companyId: agent.companyId, executionPolicy: runExecutionPolicy } : null,
|
||||
});
|
||||
}
|
||||
|
||||
function buildLowTrustSelfView(agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
|
||||
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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
: null;
|
||||
const runExecutionPolicy = runContext?.executionPolicy && typeof runContext.executionPolicy === "object"
|
||||
? runContext.executionPolicy as Record<string, unknown>
|
||||
: 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<typeof getActorInfo>,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user