fix: close low-trust self detail bypass

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 18:25:20 +00:00
1 parent 0a2a16a26d
commit da1778fbf8
7 files changed
+59 -36

No files matched your search

@@ -615,6 +615,20 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
expect(lowTrustRes.body).not.toHaveProperty("access");
expectNoCanary(lowTrustRes.body, fixture.canaries.agentConfig);
const lowTrustSelfByIdRes = await request(createApp(db, agentActor(fixture)))
.get(`/api/agents/${fixture.agents.lowTrust.id}`);
expect(lowTrustSelfByIdRes.status, JSON.stringify(lowTrustSelfByIdRes.body)).toBe(200);
expect(lowTrustSelfByIdRes.body).toMatchObject({
id: fixture.agents.lowTrust.id,
companyId: fixture.company.id,
trustPreset: LOW_TRUST_REVIEW_PRESET,
});
expect(lowTrustSelfByIdRes.body).not.toHaveProperty("adapterConfig");
expect(lowTrustSelfByIdRes.body).not.toHaveProperty("runtimeConfig");
expect(lowTrustSelfByIdRes.body).not.toHaveProperty("permissions");
expect(lowTrustSelfByIdRes.body).not.toHaveProperty("access");
expectNoCanary(lowTrustSelfByIdRes.body, fixture.canaries.agentConfig);
const standardActor = agentActor(fixture, fixture.agents.standard.id);
const standardRes = await request(createApp(db, { ...standardActor, runId: null })).get("/api/agents/me");
expect(standardRes.status, JSON.stringify(standardRes.body)).toBe(200);
+5
View File
@@ -0,0 +1,5 @@
export function readObject(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
+12 -6
View File
@@ -101,6 +101,7 @@ 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";
import { readObject } from "../lib/objects.js";
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
@@ -118,12 +119,6 @@ function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) {
return Math.min(max, Math.trunc(parsed));
}
function readObject(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function readRunIssueId(context: Record<string, unknown> | null) {
const directIssueId = context?.issueId;
if (typeof directIssueId === "string" && isUuidLike(directIssueId)) return directIssueId;
@@ -1915,6 +1910,17 @@ export function agentRoutes(
assertCompanyAccess(req, agent.companyId);
if (!(await assertAgentReadAllowed(req, res, agent))) return;
const isSelf = req.actor.type === "agent" && req.actor.agentId === id;
if (isSelf) {
const trustPreset = await resolveAgentSelfTrustPreset(req, agent);
if (trustPreset.kind === "denied") {
res.status(403).json({ error: trustPreset.detail });
return;
}
if (trustPreset.kind === "low_trust_review") {
res.json(buildLowTrustSelfView(agent));
return;
}
}
const canReadSensitiveDetail = isSelf
? true
: await actorCanReadConfigurationsForCompany(req, agent.companyId);
@@ -7,6 +7,7 @@ import { assertCompanyAccess } from "./authz.js";
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import { isLowTrustRuntimeManagementAllowed } from "../services/low-trust-runtime-containment.js";
import { resolveCoreTrustPreset, type TrustPresetResolution } from "../services/trust-preset-resolver.js";
import { readObject } from "../lib/objects.js";
const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [
"backlog",
@@ -16,12 +17,6 @@ const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [
"blocked",
];
function readObject(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function readRunIssueId(context: Record<string, unknown> | null) {
const directIssueId = context?.issueId;
if (typeof directIssueId === "string" && directIssueId.length > 0) return directIssueId;
+22 -14
View File
@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -618,19 +618,27 @@ export function authorizationService(db: Db) {
}
async function issueIdIsDescendantOf(issueId: string, rootIssueId: string, companyId: string) {
let cursor: string | null = issueId;
// Keep list filtering bounded. Deep task trees outside this limit are treated as outside the boundary.
for (let depth = 0; cursor && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth += 1) {
if (cursor === rootIssueId) return true;
const row: { id: string; companyId: string; parentId: string | null } | null = await db
.select({ id: issues.id, companyId: issues.companyId, parentId: issues.parentId })
.from(issues)
.where(eq(issues.id, cursor))
.then((rows) => rows[0] ?? null);
if (!row || row.companyId !== companyId) return false;
cursor = row.parentId;
}
return false;
const rows = await db.execute(sql`
WITH RECURSIVE ancestors(id, parent_id, depth) AS (
SELECT id, parent_id, 0
FROM issues
WHERE company_id = ${companyId}
AND id = ${issueId}
UNION ALL
SELECT parent.id, parent.parent_id, ancestors.depth + 1
FROM issues parent
JOIN ancestors ON parent.id = ancestors.parent_id
WHERE parent.company_id = ${companyId}
AND ancestors.depth < ${LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH - 1}
)
SELECT EXISTS(SELECT 1 FROM ancestors WHERE id = ${rootIssueId}) AS is_descendant
`);
const first = Array.isArray(rows) ? rows[0] : null;
return Boolean(
first &&
typeof first === "object" &&
(first as Record<string, unknown>).is_descendant === true,
);
}
async function issueResourceWithinLowTrustBoundary(
+4 -4
View File
@@ -8100,15 +8100,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
)
: [];
})();
assertLowTrustRuntimeServicesAllowed({
resolution: trustPreset,
runtimeServiceCount: runtimeServiceIntents.length,
});
if (runtimeServiceIntents.length > 0) {
context.paperclipRuntimeServiceIntents = runtimeServiceIntents;
} else {
delete context.paperclipRuntimeServiceIntents;
}
assertLowTrustRuntimeServicesAllowed({
resolution: trustPreset,
runtimeServiceCount: runtimeServiceIntents.length,
});
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
context.projectId = executionWorkspace.projectId;
}
+1 -6
View File
@@ -6,6 +6,7 @@ import {
type SourceTrustMetadata,
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { readObject } from "../lib/objects.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
export const LOW_TRUST_QUARANTINED_BODY =
@@ -70,12 +71,6 @@ export function buildLowTrustSourceTrust(input: {
};
}
function readObject(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
export function buildPromotedSourceTrust(input: {
sourceIssueId: string;
sourceArtifactKind: "comment" | "document" | "work_product" | "issue";