mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix: close low-trust greptile auth gaps
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3d2cf8fa71
commit
325b67ca29
6 files changed
+130
-23
No files matched your search
@@ -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({
|
||||
|
||||
@@ -122,7 +122,7 @@ describeEmbeddedPostgres("resolveActorSourceTrustForIssue", () => {
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function createAgent(companyId: string) {
|
||||
async function createAgent(companyId: string, permissions: Record<string, unknown> = {}) {
|
||||
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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
+40
-11
@@ -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<TrustPresetResolution | null> {
|
||||
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<string, unknown>
|
||||
: 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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user