fix: close low-trust greptile auth gaps

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 18:06:39 +00:00
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({
+45 -2
View File
@@ -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
View File
@@ -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");
}
+4
View File
@@ -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,