Add CE low-trust boundary editor

This commit is contained in:
Dotta committed 2026-06-05 18:06:39 +00:00
1 parent 323618b453
commit 736bda666d
7 files changed
+493 -8

No files matched your search

@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentPermissions } from "@paperclipai/shared";
import { TrustPresetSection } from "./TrustPresetSection";
import { TooltipProvider } from "@/components/ui/tooltip";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
let container: HTMLDivElement | null = null;
function renderSection(permissions: Partial<AgentPermissions>) {
container = document.createElement("div");
document.body.appendChild(container);
const onChange = vi.fn();
const createdRoot = createRoot(container);
root = createdRoot;
flushSync(() => {
createdRoot.render(
<TooltipProvider>
<TrustPresetSection
permissions={permissions}
onChange={onChange}
companyId="company-1"
projectCandidates={[{ id: "project-1", label: "Paperclip App" }]}
issueCandidates={[{ id: "issue-1", label: "PAP-1 · Review PR" }]}
/>
</TooltipProvider>,
);
});
return { onChange, text: () => container?.textContent ?? "" };
}
afterEach(() => {
if (root) {
flushSync(() => root?.unmount());
}
container?.remove();
root = null;
container = null;
});
describe("TrustPresetSection", () => {
it("hides the CE boundary editor under Standard", () => {
const view = renderSection({ canCreateAgents: false, trustPreset: "standard" });
expect(view.text()).toContain("Trust preset");
expect(view.text()).not.toContain("Boundary type");
expect(view.text()).not.toContain("Get Paperclip EE.");
});
it("shows a selectable CE boundary editor for low-trust review", () => {
const view = renderSection({
canCreateAgents: false,
trustPreset: "low_trust_review",
authorizationPolicy: {
trustPreset: "low_trust_review",
trustBoundary: {
mode: "low_trust_review",
companyId: "company-1",
projectIds: ["project-1"],
},
},
});
expect(view.text()).toContain("Containment active");
expect(view.text()).toContain("Boundary type");
expect(view.text()).toContain("Paperclip App");
expect(view.text()).toContain("Get Paperclip EE.");
expect(view.text()).not.toContain("Managed by EE/API");
});
it("renders multi-boundary policies as read-only", () => {
const view = renderSection({
canCreateAgents: false,
trustPreset: "low_trust_review",
authorizationPolicy: {
trustPreset: "low_trust_review",
trustBoundary: {
mode: "low_trust_review",
companyId: "company-1",
projectIds: ["project-1", "project-2"],
},
},
});
expect(view.text()).toContain("Managed by EE/API");
expect(view.text()).toContain("2 boundaries");
expect(view.text()).not.toContain("Clear boundary");
});
});
+122 -1
View File
@@ -1,14 +1,21 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { AgentPermissions, TrustPreset } from "@paperclipai/shared";
import { Lock, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Field, CollapsibleSection } from "./agent-config-primitives";
import {
buildPermissionsForTrustPreset,
clearSingleLowTrustBoundaryTarget,
getLowTrustBoundary,
getSingleLowTrustBoundaryTarget,
getTrustPreset,
isCeLowTrustBoundaryEditable,
lowTrustBoundaryHasScope,
setSingleLowTrustBoundaryTarget,
summarizeLowTrustBoundaryTarget,
TRUST_PRESET_DESCRIPTIONS,
TRUST_PRESET_LABELS,
type LowTrustBoundaryTarget,
} from "../lib/trust-policy-ui";
import { cn } from "../lib/utils";
@@ -30,31 +37,71 @@ function PolicyRow({ label, value }: { label: string; value: string }) {
);
}
export interface LowTrustBoundaryCandidate {
id: string;
label: string;
}
type LowTrustBoundaryTargetType = LowTrustBoundaryTarget["type"];
const BOUNDARY_TARGET_LABELS: Record<LowTrustBoundaryTargetType, string> = {
project: "Project",
root_issue: "Root issue",
issue: "Issue",
};
export function TrustPresetSection({
permissions,
onChange,
disabled,
companyId,
projectCandidates = [],
issueCandidates = [],
candidatesLoading,
}: {
permissions: Partial<AgentPermissions> | null | undefined;
onChange: (permissions: Partial<AgentPermissions>) => void;
disabled?: boolean;
companyId?: string | null;
projectCandidates?: LowTrustBoundaryCandidate[];
issueCandidates?: LowTrustBoundaryCandidate[];
candidatesLoading?: boolean;
}) {
const [policyOpen, setPolicyOpen] = useState(false);
const preset = getTrustPreset(permissions);
const boundary = getLowTrustBoundary(permissions);
const boundaryTarget = getSingleLowTrustBoundaryTarget(boundary);
const [targetType, setTargetType] = useState<LowTrustBoundaryTargetType>(boundaryTarget?.type ?? "project");
const lowTrust = preset === "low_trust_review";
const hasScope = lowTrustBoundaryHasScope(boundary);
const boundaryEditable = isCeLowTrustBoundaryEditable(boundary);
const policy = permissions?.authorizationPolicy ?? null;
const managedPermissions = useMemo(
() => buildPermissionsForTrustPreset(permissions, preset),
[permissions, preset],
);
useEffect(() => {
if (boundaryTarget) setTargetType(boundaryTarget.type);
}, [boundaryTarget?.type]);
function handlePresetChange(value: string) {
const nextPreset: TrustPreset = value === "low_trust_review" ? "low_trust_review" : "standard";
onChange(buildPermissionsForTrustPreset(permissions, nextPreset));
}
function handleBoundaryTargetChange(targetId: string) {
if (!companyId || !targetId) return;
onChange(setSingleLowTrustBoundaryTarget(permissions, companyId, { type: targetType, id: targetId }));
}
function handleClearBoundary() {
onChange(clearSingleLowTrustBoundaryTarget(permissions));
}
const targetCandidates = targetType === "project" ? projectCandidates : issueCandidates;
const boundaryValue = boundaryTarget?.type === targetType ? boundaryTarget.id : "";
return (
<div>
<h3 className="mb-3 text-sm font-medium">Trust</h3>
@@ -99,6 +146,80 @@ export function TrustPresetSection({
: "This agent is set to low-trust review, but no project, root issue, or issue scope is set in the core policy. Add a scope before this agent can run without denial."}
</p>
</div>
{boundaryEditable ? (
<div className="rounded-md border border-border/70 bg-background/70 p-3 text-foreground space-y-3">
<div className="grid gap-3 sm:grid-cols-[minmax(0,0.75fr)_minmax(0,1fr)]">
<Field label="Boundary type">
<select
className={inputClass}
value={targetType}
onChange={(event) => setTargetType(event.target.value as LowTrustBoundaryTargetType)}
disabled={disabled}
>
<option value="project">Project</option>
<option value="root_issue">Root issue</option>
<option value="issue">Issue</option>
</select>
</Field>
<Field label={BOUNDARY_TARGET_LABELS[targetType]}>
<select
className={inputClass}
value={boundaryValue}
onChange={(event) => handleBoundaryTargetChange(event.target.value)}
disabled={disabled || !companyId || candidatesLoading || targetCandidates.length === 0}
>
<option value="">
{candidatesLoading
? "Loading…"
: targetCandidates.length === 0
? `No ${targetType === "project" ? "projects" : "issues"} available`
: "Select boundary"}
</option>
{targetCandidates.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.label}
</option>
))}
</select>
</Field>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
CE saves one containment boundary at a time. Saved policies include this company id.
</p>
{boundaryTarget ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
onClick={handleClearBoundary}
disabled={disabled}
>
Clear boundary
</Button>
) : null}
</div>
</div>
) : (
<div className="rounded-md border border-border/70 bg-background/70 p-3 text-foreground">
<p className="text-sm font-medium">Managed by EE/API</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
This policy has {summarizeLowTrustBoundaryTarget(boundary).toLowerCase()} and cannot be edited by the CE single-boundary editor.
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
Want to set more than one containment boundary?{" "}
<a
className="underline underline-offset-2 hover:text-foreground"
href="https://paperclip.ing/ee"
target="_blank"
rel="noreferrer"
>
Get Paperclip EE.
</a>
</p>
<CollapsibleSection
title="View policy"
open={policyOpen}
+10
View File
@@ -62,6 +62,11 @@ describe("buildNewAgentHirePayload", () => {
version: 1,
rawOutputDisposition: "quarantine",
},
trustBoundary: {
mode: "low_trust_review",
companyId: "company-1",
rootIssueId: "issue-root",
},
},
},
}),
@@ -76,6 +81,11 @@ describe("buildNewAgentHirePayload", () => {
version: 1,
rawOutputDisposition: "quarantine",
},
trustBoundary: {
mode: "low_trust_review",
companyId: "company-1",
rootIssueId: "issue-root",
},
},
},
});
+117
View File
@@ -0,0 +1,117 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
clearSingleLowTrustBoundaryTarget,
getLowTrustBoundary,
getSingleLowTrustBoundaryTarget,
isCeLowTrustBoundaryEditable,
setSingleLowTrustBoundaryTarget,
summarizeLowTrustBoundaryTarget,
} from "./trust-policy-ui";
describe("trust-policy-ui low-trust boundary helpers", () => {
it("writes one project boundary with mode and company id", () => {
const permissions = setSingleLowTrustBoundaryTarget(null, "company-1", {
type: "project",
id: "project-1",
});
expect(permissions).toMatchObject({
trustPreset: "low_trust_review",
authorizationPolicy: {
trustPreset: "low_trust_review",
reviewPreset: {
id: "low_trust_review",
version: 1,
rawOutputDisposition: "quarantine",
},
trustBoundary: {
mode: "low_trust_review",
companyId: "company-1",
projectIds: ["project-1"],
},
},
});
});
it("clears other scope fields while preserving non-scope policy fields", () => {
const permissions = setSingleLowTrustBoundaryTarget(
{
trustPreset: "low_trust_review",
authorizationPolicy: {
customEeField: { mode: "preserved" },
trustBoundary: {
mode: "low_trust_review",
companyId: "company-1",
projectIds: ["project-1"],
rootIssueId: "root-1",
issueIds: ["issue-1"],
allowedToolClasses: ["git.read"],
},
},
},
"company-2",
{ type: "issue", id: "issue-2" },
);
expect(permissions.authorizationPolicy).toMatchObject({
customEeField: { mode: "preserved" },
trustBoundary: {
mode: "low_trust_review",
companyId: "company-2",
issueIds: ["issue-2"],
allowedToolClasses: ["git.read"],
},
});
expect(permissions.authorizationPolicy?.trustBoundary?.projectIds).toBeUndefined();
expect(permissions.authorizationPolicy?.trustBoundary?.rootIssueId).toBeUndefined();
});
it("hydrates one existing root issue boundary", () => {
const boundary = getLowTrustBoundary({
trustPreset: "low_trust_review",
authorizationPolicy: {
trustBoundary: {
mode: "low_trust_review",
companyId: "company-1",
rootIssueId: "issue-root",
},
},
});
expect(getSingleLowTrustBoundaryTarget(boundary)).toEqual({ type: "root_issue", id: "issue-root" });
expect(summarizeLowTrustBoundaryTarget(boundary)).toBe("Root issue issue-ro");
});
it("marks multi-boundary policies read-only for CE", () => {
const boundary = {
mode: "low_trust_review" as const,
companyId: "company-1",
projectIds: ["project-1", "project-2"],
};
expect(isCeLowTrustBoundaryEditable(boundary)).toBe(false);
expect(getSingleLowTrustBoundaryTarget(boundary)).toBeNull();
expect(summarizeLowTrustBoundaryTarget(boundary)).toBe("2 boundaries");
});
it("clears the CE boundary without removing non-scope fields", () => {
const permissions = clearSingleLowTrustBoundaryTarget({
trustPreset: "low_trust_review",
authorizationPolicy: {
trustBoundary: {
mode: "low_trust_review",
companyId: "company-1",
issueIds: ["issue-1"],
allowedSecretBindingIds: ["binding-1"],
},
},
});
expect(permissions.authorizationPolicy?.trustBoundary).toEqual({
mode: "low_trust_review",
companyId: "company-1",
allowedSecretBindingIds: ["binding-1"],
});
});
});
+94 -5
View File
@@ -10,6 +10,11 @@ import {
type TrustPreset,
} from "@paperclipai/shared";
export type LowTrustBoundaryTarget =
| { type: "project"; id: string }
| { type: "root_issue"; id: string }
| { type: "issue"; id: string };
export const TRUST_PRESET_LABELS: Record<TrustPreset, string> = {
standard: "Standard",
low_trust_review: "Low-trust review",
@@ -73,14 +78,98 @@ export function getLowTrustBoundary(
return boundary?.mode === LOW_TRUST_REVIEW_PRESET ? boundary : null;
}
export function lowTrustBoundaryHasScope(boundary: LowTrustBoundary | null | undefined) {
return Boolean(
boundary?.projectIds?.length ||
boundary?.rootIssueId ||
boundary?.issueIds?.length,
function countBoundaryTargets(boundary: LowTrustBoundary | null | undefined) {
return (
(boundary?.projectIds?.length ?? 0) +
(boundary?.rootIssueId ? 1 : 0) +
(boundary?.issueIds?.length ?? 0)
);
}
export function getSingleLowTrustBoundaryTarget(
boundary: LowTrustBoundary | null | undefined,
): LowTrustBoundaryTarget | null {
if (!boundary || countBoundaryTargets(boundary) !== 1) return null;
const projectId = boundary.projectIds?.[0];
if (projectId) return { type: "project", id: projectId };
if (boundary.rootIssueId) return { type: "root_issue", id: boundary.rootIssueId };
const issueId = boundary.issueIds?.[0];
if (issueId) return { type: "issue", id: issueId };
return null;
}
export function isCeLowTrustBoundaryEditable(boundary: LowTrustBoundary | null | undefined) {
return countBoundaryTargets(boundary) <= 1;
}
export function setSingleLowTrustBoundaryTarget(
permissions: Partial<AgentPermissions> | null | undefined,
companyId: string,
target: LowTrustBoundaryTarget,
): Partial<AgentPermissions> {
const current = buildPermissionsForTrustPreset(permissions, LOW_TRUST_REVIEW_PRESET);
const currentPolicy = current.authorizationPolicy ?? {};
const existingBoundary = currentPolicy.trustBoundary ?? { mode: LOW_TRUST_REVIEW_PRESET };
const { projectIds: _projectIds, rootIssueId: _rootIssueId, issueIds: _issueIds, ...nonScopeBoundary } = existingBoundary;
const trustBoundary: LowTrustBoundary = {
...nonScopeBoundary,
mode: LOW_TRUST_REVIEW_PRESET,
companyId,
...(target.type === "project" ? { projectIds: [target.id] } : {}),
...(target.type === "root_issue" ? { rootIssueId: target.id } : {}),
...(target.type === "issue" ? { issueIds: [target.id] } : {}),
};
return {
...current,
authorizationPolicy: {
...currentPolicy,
trustPreset: LOW_TRUST_REVIEW_PRESET,
reviewPreset: {
id: LOW_TRUST_REVIEW_PRESET,
version: LOW_TRUST_REVIEW_PRESET_VERSION,
rawOutputDisposition: LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION,
},
trustBoundary,
},
};
}
export function clearSingleLowTrustBoundaryTarget(
permissions: Partial<AgentPermissions> | null | undefined,
): Partial<AgentPermissions> {
const current = buildPermissionsForTrustPreset(permissions, LOW_TRUST_REVIEW_PRESET);
const currentPolicy = current.authorizationPolicy ?? {};
const boundary = currentPolicy.trustBoundary;
if (!boundary) return current;
const { projectIds: _projectIds, rootIssueId: _rootIssueId, issueIds: _issueIds, ...nonScopeBoundary } = boundary;
return {
...current,
authorizationPolicy: {
...currentPolicy,
trustBoundary: {
...nonScopeBoundary,
mode: LOW_TRUST_REVIEW_PRESET,
},
},
};
}
export function summarizeLowTrustBoundaryTarget(
boundary: LowTrustBoundary | null | undefined,
) {
const target = getSingleLowTrustBoundaryTarget(boundary);
if (target?.type === "project") return `Project ${target.id.slice(0, 8)}`;
if (target?.type === "root_issue") return `Root issue ${target.id.slice(0, 8)}`;
if (target?.type === "issue") return `Issue ${target.id.slice(0, 8)}`;
if (!boundary || countBoundaryTargets(boundary) === 0) return "No boundary selected";
return `${countBoundaryTargets(boundary)} boundaries`;
}
export function lowTrustBoundaryHasScope(boundary: LowTrustBoundary | null | undefined) {
return countBoundaryTargets(boundary) > 0;
}
export function sourceTrustLabel(sourceTrust: SourceTrustMetadata | null | undefined) {
if (!sourceTrust || sourceTrust.preset !== LOW_TRUST_REVIEW_PRESET) return null;
if (sourceTrust.disposition === "promoted") return "Promoted from low-trust";
+28 -1
View File
@@ -15,6 +15,7 @@ import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
@@ -102,7 +103,7 @@ import {
type LiveEvent,
type WorkspaceOperation,
} from "@paperclipai/shared";
import { buildPermissionsForTrustPreset } from "../lib/trust-policy-ui";
import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
import {
@@ -1731,6 +1732,22 @@ function ConfigurationTab({
enabled: Boolean(companyId),
});
const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review";
const { data: boundaryProjects, isLoading: boundaryProjectsLoading } = useQuery({
queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__low-trust-disabled"],
queryFn: () => projectsApi.list(companyId!),
enabled: Boolean(companyId && lowTrustSelected),
});
const { data: boundaryIssues, isLoading: boundaryIssuesLoading } = useQuery({
queryKey: companyId
? [...queryKeys.issues.list(companyId), "low-trust-boundary-candidates"]
: ["issues", "__low-trust-disabled"],
queryFn: () => issuesApi.list(companyId!, { limit: 100, sortField: "updated", sortDir: "desc" }),
enabled: Boolean(companyId && lowTrustSelected),
});
const updateAgent = useMutation({
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
onMutate: () => {
@@ -1801,6 +1818,16 @@ function ConfigurationTab({
<TrustPresetSection
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
projectCandidates={(boundaryProjects ?? []).map((project) => ({
id: project.id,
label: project.name,
}))}
issueCandidates={(boundaryIssues ?? []).map((issue) => ({
id: issue.id,
label: `${issue.identifier ?? issue.id.slice(0, 8)} · ${issue.title}`,
}))}
candidatesLoading={boundaryProjectsLoading || boundaryIssuesLoading}
onChange={(nextPermissions) =>
updatePermissions.mutate({
canCreateAgents,
+29 -1
View File
@@ -5,6 +5,8 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { agentsApi } from "../api/agents";
import { companySkillsApi } from "../api/companySkills";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { AGENT_ROLES, type AdapterEnvironmentTestResult, type AgentPermissions } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
@@ -29,7 +31,7 @@ import { isValidAdapterType } from "../adapters/metadata";
import { ReportsToPicker } from "../components/ReportsToPicker";
import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
import { TrustPresetSection } from "../components/TrustPresetSection";
import { buildPermissionsForTrustPreset } from "../lib/trust-policy-ui";
import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
@@ -98,6 +100,22 @@ export function NewAgent() {
enabled: Boolean(selectedCompanyId),
});
const lowTrustSelected = getTrustPreset(permissions) === "low_trust_review";
const { data: boundaryProjects, isLoading: boundaryProjectsLoading } = useQuery({
queryKey: selectedCompanyId ? queryKeys.projects.list(selectedCompanyId) : ["projects", "__low-trust-disabled"],
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId && lowTrustSelected),
});
const { data: boundaryIssues, isLoading: boundaryIssuesLoading } = useQuery({
queryKey: selectedCompanyId
? [...queryKeys.issues.list(selectedCompanyId), "low-trust-boundary-candidates"]
: ["issues", "__low-trust-disabled"],
queryFn: () => issuesApi.list(selectedCompanyId!, { limit: 100, sortField: "updated", sortDir: "desc" }),
enabled: Boolean(selectedCompanyId && lowTrustSelected),
});
const isFirstAgent = !agents || agents.length === 0;
const effectiveRole = isFirstAgent ? "ceo" : role;
@@ -267,6 +285,16 @@ export function NewAgent() {
permissions={permissions}
onChange={setPermissions}
disabled={createAgent.isPending}
companyId={selectedCompanyId}
projectCandidates={(boundaryProjects ?? []).map((project) => ({
id: project.id,
label: project.name,
}))}
issueCandidates={(boundaryIssues ?? []).map((issue) => ({
id: issue.id,
label: `${issue.identifier ?? issue.id.slice(0, 8)} · ${issue.title}`,
}))}
candidatesLoading={boundaryProjectsLoading || boundaryIssuesLoading}
/>
</div>