mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
Bind hosted controller admission to the repository, GitHub run, first attempt, workflow revision and selected execution before credential reads or provider launch. Keep offline evidence replay independent of the current worker environment. Preserve the single-project boundary editor and refresh project-access data with agent-detail updates.
Verified head: 1e588f75fe.
Validation: 74 synthetic source/portable controller tests, 98 focused UI tests and token gates passed before the ancestry-only final sync; four hosted/portable regressions pass again with unchanged eval and UI repair trees. Final exact-head full CI passed (40387856225), and Greptile reviewed this head at 5/5 with zero unresolved findings. The optional secondary Paperclip Review is paused and not counted as completed. No paid work or schedule changes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } 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";
|
|
|
|
const inputClass =
|
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
|
|
|
function formatCount(value: readonly unknown[] | undefined, singular: string, plural: string) {
|
|
const count = value?.length ?? 0;
|
|
if (count === 0) return "-";
|
|
return `${count} ${count === 1 ? singular : plural}`;
|
|
}
|
|
|
|
function PolicyRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex items-start justify-between gap-3 py-1.5 text-sm">
|
|
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
|
<span className={cn("min-w-0 text-right", value === "-" && "text-muted-foreground")}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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,
|
|
allowSingleIssue = true,
|
|
projectAccessEditor,
|
|
}: {
|
|
permissions: Partial<AgentPermissions> | null | undefined;
|
|
onChange: (permissions: Partial<AgentPermissions>) => void;
|
|
disabled?: boolean;
|
|
companyId?: string | null;
|
|
projectCandidates?: LowTrustBoundaryCandidate[];
|
|
issueCandidates?: LowTrustBoundaryCandidate[];
|
|
candidatesLoading?: boolean;
|
|
allowSingleIssue?: boolean;
|
|
projectAccessEditor?: ReactNode;
|
|
}) {
|
|
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>
|
|
<div className="rounded-lg border border-border p-4 space-y-3">
|
|
<Field label="Trust preset" hint="Choose how broadly this agent can read and act on Paperclip work objects.">
|
|
<select
|
|
className={inputClass}
|
|
value={preset}
|
|
onChange={(event) => handlePresetChange(event.target.value)}
|
|
disabled={disabled}
|
|
>
|
|
<option value="standard">{TRUST_PRESET_LABELS.standard}</option>
|
|
<option value="low_trust_review">{TRUST_PRESET_LABELS.low_trust_review}</option>
|
|
</select>
|
|
</Field>
|
|
<p className="text-xs text-muted-foreground">{TRUST_PRESET_DESCRIPTIONS[preset]}</p>
|
|
|
|
{lowTrust ? (
|
|
<div
|
|
role={hasScope ? "status" : "alert"}
|
|
aria-live="polite"
|
|
className={cn(
|
|
"rounded-md border px-3 py-2.5 text-sm flex gap-2",
|
|
hasScope
|
|
? "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-100"
|
|
: "border-destructive/30 bg-destructive/10 text-destructive",
|
|
)}
|
|
>
|
|
{hasScope ? (
|
|
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
|
) : (
|
|
<Lock className="mt-0.5 h-4 w-4 shrink-0" />
|
|
)}
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<div>
|
|
<p className="font-medium">
|
|
{hasScope ? "Containment active" : "Containment not configured"}
|
|
</p>
|
|
<p className="mt-1 text-xs leading-5">
|
|
{hasScope
|
|
? "This agent can only read and mutate work inside its assigned review boundary. Raw output is quarantined from higher-trust agents until a trusted reviewer promotes it."
|
|
: "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>
|
|
{projectAccessEditor && (boundary?.projectIds?.length ?? 0) > 1 ? null : 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-(--gtc-12)">
|
|
<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>
|
|
{allowSingleIssue && <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 organization 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>
|
|
)}
|
|
{!projectAccessEditor && <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}
|
|
onToggle={() => setPolicyOpen((open) => !open)}
|
|
>
|
|
<div className="divide-y divide-border/60 text-foreground">
|
|
<PolicyRow label="Preset" value="Low-trust review v1" />
|
|
<PolicyRow label="Raw output" value="Quarantined from higher-trust agents" />
|
|
<PolicyRow label="Projects" value={formatCount(boundary?.projectIds, "project", "projects")} />
|
|
<PolicyRow label="Root issue" value={boundary?.rootIssueId ? boundary.rootIssueId.slice(0, 8) : "-"} />
|
|
<PolicyRow label="Explicit issues" value={formatCount(boundary?.issueIds, "issue", "issues")} />
|
|
<PolicyRow label="Allowed agents" value={formatCount(boundary?.allowedAgentIds, "agent", "agents")} />
|
|
<PolicyRow label="Allowed tools" value={boundary?.allowedToolClasses?.join(" · ") || "-"} />
|
|
<PolicyRow label="Allowed secrets" value={formatCount(boundary?.allowedSecretBindingIds, "binding", "bindings")} />
|
|
<PolicyRow label="Promotion target" value={boundary?.outputPromotionTarget?.issueId?.slice(0, 8) ?? "-"} />
|
|
<PolicyRow
|
|
label="EE fields"
|
|
value={Object.keys(policy ?? {}).some((key) => !["trustPreset", "reviewPreset", "trustBoundary"].includes(key))
|
|
? "Custom advanced policy fields preserved"
|
|
: "-"}
|
|
/>
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{lowTrust && projectAccessEditor}
|
|
|
|
{managedPermissions.authorizationPolicy?.reviewPreset ? null : (
|
|
<p className="text-xs text-muted-foreground">
|
|
Advanced permissions remain editable through the EE permissions extension when installed.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|