Add low-trust review containment (#7530)
## Thinking Path > - Paperclip is a control plane for AI-agent companies, so execution policy and trust boundaries are part of the product's safety contract. > - Low-trust review work needs narrower authority than normal same-company agents because hostile PRs, comments, attachments, and generated output can carry prompt-injection payloads. > - The current V1 shape gives trusted workers broad company context, which is useful for normal execution but too permissive for a reviewer assigned to hostile content. > - This branch adds a `low_trust_review` preset, source-trust tagging, route-level containment, and quarantine handling so low-trust output does not automatically flow into higher-trust wake context. > - The branch has been rebased onto current `origin/master`, and the low-trust migration was renumbered to `0097_low_trust_source_trust.sql` to avoid collisions with existing `0091` through `0096` migrations. > - Greptile feedback was addressed by tightening low-trust detection, preserving project-level trust policy checks, fixing issue-kind promotion lookup, removing duplicate post-lease isolation assertion, documenting fail-closed source-trust behavior, bounding ancestry checks, enforcing runtime issue context for CEOs, awaiting accepted-plan monitor authorization, and making low-trust issue source-trust tagging atomic. > - The benefit is a first production slice of deny-by-default review containment with regression coverage for the main control-plane pivot surfaces. Fixes #7531. ## What Changed - Added shared trust-policy types and validators, plus database/source-trust fields for issues, comments, documents, and work products. - Implemented server enforcement for low-trust issue scope, agent self-view redaction, secret/plugin/runtime denial paths, promotion checks, and quarantined continuation/wake context. - Added focused low-trust regression tests for resolver behavior, source trust, route authorization, heartbeat preflight ordering, runtime containment, and quarantine redaction. - Added board UI affordances for selecting/reviewing the low-trust preset and surfacing source-trust badges in relevant issue views. - Added `doc/LOW-TRUST-PRESETS.md`, updated `doc/SPEC-implementation.md`, and committed the low-trust review contract plan under `doc/plans/`. - Rebasing note: the original `0097_low_trust_source_trust.sql` migration was renamed to `0097_low_trust_source_trust.sql`; the SQL uses `ADD COLUMN IF NOT EXISTS` so users who already applied the old-numbered migration are not broken by the renumbered migration. ## Verification - Rebased branch onto current `origin/master` and force-pushed with lease to `origin/PAP-10211-low-trust-agent` at head `2719f31e3`. - Confirmed the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Resolved upstream UI/comment conflicts by preserving deleted-comment tombstone behavior and low-trust source-trust badges/metadata. - Renumbered the low-trust source-trust migration to `0097_low_trust_source_trust.sql`; the SQL uses `ADD COLUMN IF NOT EXISTS` so users who already applied an old-numbered copy are not broken. - `pnpm exec vitest run ui/src/lib/issue-chat-messages.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm exec vitest run server/src/__tests__/source-trust.test.ts server/src/__tests__/workspace-runtime-service-authz.test.ts ui/src/lib/trust-policy-ui.test.ts ui/src/components/TrustPresetSection.test.tsx` - `pnpm run typecheck:build-gaps` - `git diff --check` - GitHub checks pass on head `2719f31e3`: build, typecheck/release registry, general tests, serialized server suites, e2e, canary, verify, policy/review, Socket, and Snyk. - Greptile Review passes with Confidence Score 5/5 and zero unresolved Greptile review threads. - No design screenshots/images were added because the task explicitly says not to add them unless they are specifically part of the work. ## Risks - Medium risk: this touches shared trust-policy contracts, server authorization paths, heartbeat context generation, migration metadata, and UI preset controls. - Low-trust containment is intentionally deny-by-default; legitimate future review workflows may need explicit allowlisted exceptions. - Plugin/runtime/security surfaces are broad, so regression tests cover the current known routes but future integrations must route through the same containment layer. - The PR is ready for review; GitHub checks are green and Greptile is 5/5. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled shell and GitHub CLI workflow. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] UI changes are covered by focused tests; no screenshots were added per task instruction not to add design images unless specifically required - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+130
-5
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db";
|
||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentSkillSyncSchema,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
wakeAgentSchema,
|
||||
updateAgentSchema,
|
||||
supportedEnvironmentDriversForAdapter,
|
||||
LOW_TRUST_REVIEW_PRESET,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
@@ -99,6 +100,8 @@ 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";
|
||||
import { readObject } from "../lib/objects.js";
|
||||
|
||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||
@@ -116,6 +119,14 @@ function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) {
|
||||
return Math.min(max, Math.trunc(parsed));
|
||||
}
|
||||
|
||||
function readRunIssueId(context: Record<string, unknown> | null) {
|
||||
const directIssueId = context?.issueId;
|
||||
if (typeof directIssueId === "string" && isUuidLike(directIssueId)) return directIssueId;
|
||||
const paperclipIssue = readObject(context?.paperclipIssue);
|
||||
const nestedIssueId = paperclipIssue?.id;
|
||||
return typeof nestedIssueId === "string" && isUuidLike(nestedIssueId) ? nestedIssueId : null;
|
||||
}
|
||||
|
||||
export function agentRoutes(
|
||||
db: Db,
|
||||
options: { pluginWorkerManager?: PluginWorkerManager } = {},
|
||||
@@ -191,6 +202,35 @@ export function agentRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
async function decideAgentRead(req: Request, agent: { id: string; companyId: string }) {
|
||||
return access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent:read",
|
||||
resource: { type: "agent", companyId: agent.companyId, agentId: agent.id },
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAgentReadAllowed(req: Request, res: Response, agent: { id: string; companyId: string }) {
|
||||
const decision = await decideAgentRead(req, agent);
|
||||
if (decision.allowed) return true;
|
||||
res.status(403).json({ error: "Agent is outside this actor's authorization boundary" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function filterAgentsForActor<T extends Record<string, unknown>>(
|
||||
req: Request,
|
||||
rows: T[],
|
||||
fallbackCompanyId?: string,
|
||||
) {
|
||||
const decisions = await Promise.all(rows.map((agent) => {
|
||||
const id = typeof agent.id === "string" ? agent.id : null;
|
||||
const companyId = typeof agent.companyId === "string" ? agent.companyId : fallbackCompanyId ?? null;
|
||||
if (!id || !companyId) return Promise.resolve({ allowed: false });
|
||||
return decideAgentRead(req, { id, companyId });
|
||||
}));
|
||||
return rows.filter((_, index) => decisions[index]?.allowed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the execution target the adapter should run its test probes against.
|
||||
*
|
||||
@@ -534,6 +574,69 @@ export function agentRoutes(
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAgentSelfTrustPreset(req: Request, agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
|
||||
if (req.actor.type !== "agent" || req.actor.agentId !== agent.id) {
|
||||
return { kind: "standard" as const };
|
||||
}
|
||||
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 ? readObject(run.contextSnapshot) : null;
|
||||
const runExecutionPolicy = readObject(runContext?.executionPolicy);
|
||||
const runIssueId = readRunIssueId(runContext);
|
||||
const runScopedIssue = runIssueId
|
||||
? await db
|
||||
.select({
|
||||
companyId: issuesTable.companyId,
|
||||
projectId: issuesTable.projectId,
|
||||
executionPolicy: issuesTable.executionPolicy,
|
||||
projectExecutionWorkspacePolicy: projectsTable.executionWorkspacePolicy,
|
||||
})
|
||||
.from(issuesTable)
|
||||
.leftJoin(projectsTable, and(eq(projectsTable.id, issuesTable.projectId), eq(projectsTable.companyId, issuesTable.companyId)))
|
||||
.where(and(eq(issuesTable.id, runIssueId), eq(issuesTable.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
||||
return resolveCoreTrustPreset({
|
||||
companyId: agent.companyId,
|
||||
agent,
|
||||
project: runScopedIssue?.projectId
|
||||
? {
|
||||
companyId: runScopedIssue.companyId,
|
||||
executionWorkspacePolicy: runScopedIssue.projectExecutionWorkspacePolicy,
|
||||
}
|
||||
: null,
|
||||
issue: runScopedIssue
|
||||
? {
|
||||
companyId: runScopedIssue.companyId,
|
||||
executionPolicy: runScopedIssue.executionPolicy,
|
||||
}
|
||||
: 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,
|
||||
@@ -1606,7 +1709,7 @@ export function agentRoutes(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await svc.list(companyId);
|
||||
const result = await filterAgentsForActor(req, await svc.list(companyId));
|
||||
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
|
||||
if (canReadConfigs) {
|
||||
res.json(result);
|
||||
@@ -1681,7 +1784,7 @@ export function agentRoutes(
|
||||
router.get("/companies/:companyId/org", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const tree = await svc.orgForCompany(companyId);
|
||||
const tree = await filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId);
|
||||
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
|
||||
res.json(leanTree);
|
||||
});
|
||||
@@ -1690,7 +1793,7 @@ export function agentRoutes(
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle;
|
||||
const tree = await svc.orgForCompany(companyId);
|
||||
const tree = await filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId);
|
||||
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
|
||||
const svg = renderOrgChartSvg(leanTree as unknown as OrgNode[], style);
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
@@ -1702,7 +1805,7 @@ export function agentRoutes(
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle;
|
||||
const tree = await svc.orgForCompany(companyId);
|
||||
const tree = await filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId);
|
||||
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
|
||||
const png = await renderOrgChartPng(leanTree as unknown as OrgNode[], style);
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
@@ -1727,6 +1830,15 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
res.json(await buildAgentDetail(agent));
|
||||
});
|
||||
|
||||
@@ -1796,7 +1908,19 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
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);
|
||||
@@ -2294,6 +2418,7 @@ export function agentRoutes(
|
||||
details: {
|
||||
canCreateAgents: agent.permissions?.canCreateAgents ?? false,
|
||||
canAssignTasks: effectiveCanAssignTasks,
|
||||
trustPreset: agent.permissions?.trustPreset ?? "standard",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user