diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 911465db461..c8a1a1789c8 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -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); diff --git a/server/src/lib/objects.ts b/server/src/lib/objects.ts new file mode 100644 index 00000000000..c227900c32e --- /dev/null +++ b/server/src/lib/objects.ts @@ -0,0 +1,5 @@ +export function readObject(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c876db688de..c63a86eb573 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 | null { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? value as Record - : null; -} - function readRunIssueId(context: Record | 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); diff --git a/server/src/routes/workspace-runtime-service-authz.ts b/server/src/routes/workspace-runtime-service-authz.ts index ea620e13ad4..c0b0d4c8213 100644 --- a/server/src/routes/workspace-runtime-service-authz.ts +++ b/server/src/routes/workspace-runtime-service-authz.ts @@ -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 | null { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? value as Record - : null; -} - function readRunIssueId(context: Record | null) { const directIssueId = context?.issueId; if (typeof directIssueId === "string" && directIssueId.length > 0) return directIssueId; diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 1c7da80167e..157c5d3cc01 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -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).is_descendant === true, + ); } async function issueResourceWithinLowTrustBoundary( diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ec770dd7d0e..56406a7c3a3 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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; } diff --git a/server/src/services/source-trust.ts b/server/src/services/source-trust.ts index 164a951272c..90091bed178 100644 --- a/server/src/services/source-trust.ts +++ b/server/src/services/source-trust.ts @@ -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 | null { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? value as Record - : null; -} - export function buildPromotedSourceTrust(input: { sourceIssueId: string; sourceArtifactKind: "comment" | "document" | "work_product" | "issue";