From 736bda666d82a0b1360a0660a216fe026e8c5974 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 5 Jun 2026 16:14:07 +0000 Subject: [PATCH] Add CE low-trust boundary editor --- ui/src/components/TrustPresetSection.test.tsx | 93 +++++++++++++ ui/src/components/TrustPresetSection.tsx | 123 +++++++++++++++++- ui/src/lib/new-agent-hire-payload.test.ts | 10 ++ ui/src/lib/trust-policy-ui.test.ts | 117 +++++++++++++++++ ui/src/lib/trust-policy-ui.ts | 99 +++++++++++++- ui/src/pages/AgentDetail.tsx | 29 ++++- ui/src/pages/NewAgent.tsx | 30 ++++- 7 files changed, 493 insertions(+), 8 deletions(-) create mode 100644 ui/src/components/TrustPresetSection.test.tsx create mode 100644 ui/src/lib/trust-policy-ui.test.ts diff --git a/ui/src/components/TrustPresetSection.test.tsx b/ui/src/components/TrustPresetSection.test.tsx new file mode 100644 index 00000000000..9d6b57d29e7 --- /dev/null +++ b/ui/src/components/TrustPresetSection.test.tsx @@ -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) { + container = document.createElement("div"); + document.body.appendChild(container); + const onChange = vi.fn(); + const createdRoot = createRoot(container); + root = createdRoot; + flushSync(() => { + createdRoot.render( + + + , + ); + }); + 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"); + }); +}); diff --git a/ui/src/components/TrustPresetSection.tsx b/ui/src/components/TrustPresetSection.tsx index 9ae95080c17..e9ee0cb4ea1 100644 --- a/ui/src/components/TrustPresetSection.tsx +++ b/ui/src/components/TrustPresetSection.tsx @@ -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 = { + project: "Project", + root_issue: "Root issue", + issue: "Issue", +}; + export function TrustPresetSection({ permissions, onChange, disabled, + companyId, + projectCandidates = [], + issueCandidates = [], + candidatesLoading, }: { permissions: Partial | null | undefined; onChange: (permissions: Partial) => 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(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 (

Trust

@@ -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."}

+ {boundaryEditable ? ( +
+
+ + + + + + +
+
+

+ CE saves one containment boundary at a time. Saved policies include this company id. +

+ {boundaryTarget ? ( + + ) : null} +
+
+ ) : ( +
+

Managed by EE/API

+

+ This policy has {summarizeLowTrustBoundaryTarget(boundary).toLowerCase()} and cannot be edited by the CE single-boundary editor. +

+
+ )} +

+ Want to set more than one containment boundary?{" "} + + Get Paperclip EE. + +

{ 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", + }, }, }, }); diff --git a/ui/src/lib/trust-policy-ui.test.ts b/ui/src/lib/trust-policy-ui.test.ts new file mode 100644 index 00000000000..c142c6bb6f9 --- /dev/null +++ b/ui/src/lib/trust-policy-ui.test.ts @@ -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"], + }); + }); +}); diff --git a/ui/src/lib/trust-policy-ui.ts b/ui/src/lib/trust-policy-ui.ts index b5c9540ff40..c46cbdc3447 100644 --- a/ui/src/lib/trust-policy-ui.ts +++ b/ui/src/lib/trust-policy-ui.ts @@ -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 = { 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 | null | undefined, + companyId: string, + target: LowTrustBoundaryTarget, +): Partial { + 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 | null | undefined, +): Partial { + 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"; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 183718ff604..8bbf3cf9635 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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) => agentsApi.update(agent.id, data, companyId), onMutate: () => { @@ -1801,6 +1818,16 @@ function ConfigurationTab({ ({ + 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, diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index a0d038443ee..efb9411a53d 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -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} />