mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Low-trust agents may read only the projects in their allowed scope. > - Workspace setup correctly stops a task outside that scope. > - The task previously showed a generic failure, and agent settings did not offer an additive repair. > - This pull request explains the denial and lets authorized operators add projects while preserving other restrictions. > - The saved task then offers an explicit retry. Saving access does not start work. ## Linked Issues or Issue Description **What happened?** A project access denial appeared as an ordinary failed attempt. Operators could not add a second allowed project from agent settings. **Expected behavior** The task should explain the denied access and link to a working permission editor. The editor should preserve existing scope and restrictions, enforce operator authority, and retain drafts after a failed save. **Steps to reproduce** 1. Assign a low-trust agent to a task whose project is outside its allowed projects. 2. Let workspace setup deny project access. 3. Open the task, follow Manage project access, add the project, save, reload, and return to the task. 4. Confirm that the saved message remains and Retry task is a separate action. ## What Changed - Record a typed, non-retryable project denial before provider dispatch. Project denials do not enter automatic continuation or provider comment policy. - Project current and historical setup denials into a safe task notice. Keep unrelated failures, prior assignees, and approval/pause/budget gates separate. Recheck current project authority, including standard trust and root-issue grants. Accepted retries keep their working state. - Add board-only project-access reads and additive saves under the agent row lock. Enforce company scope, agent-management authority, pending-approval restrictions, and an audit record for new additions. - Add the production notice and searchable multi-project editor, including read-only, saving, cancellation, and failed-save states. Preserve the return-to-task query through canonical agent redirects. - Register board-only project-access routes in OpenAPI with a strict request schema. - Add production Storybook examples and API/persistence/concurrency regressions. Retain the approved design snapshot and update the behavior maps and implementation contract. ## Verification - Passed: `pnpm -r typecheck`, `pnpm build`, `pnpm build-storybook`, `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs`. - Passed: 11 permission/projection tests and 30 run-completion tests against an isolated PostgreSQL database. These cover historical failures, concurrent additions, idempotent audit, preserved restrictions, foreign projects, read-only users, agent self-grants, and no automatic wakeup. - Passed: all 27 OpenAPI route tests, including route coverage and the new board-only request/response contract. - Browser: the real app displayed a historical denial; Manage project access opened the editor; saving retained the original project and survived reload; Return to task showed the ready notice. Saving created one audit event and zero runs. Explicit Retry returned 202 and reached the separate sandbox requirement, which remained enforced. No external provider was used. - All five production Storybook interaction checks pass: blocked task, save, cancellation, failed-save recovery, and read-only settings. These use fixture callbacks; durable authorization is covered separately above. - `pnpm test:run` was attempted locally but stopped after repeated embedded PostgreSQL startup failures caused by macOS semaphore exhaustion. Full-suite CI is required before merge. The separate UI suite passed all 9,095 tests. The 41 focused server regressions also passed after rebasing onto the latest master. A further 26 recovery-classification tests pass, including a red-to-green regression for automatic continuation after a project denial. ## Risks - No schema migration. The new endpoint only adds same-company project IDs and preserves the rest of the stored policy. - The historical notice recognizes one exact setup denial message. It does not display arbitrary run errors or disclose a different source project's identity. - The retry still passes all current admission and isolation checks. The notice does not promise that other execution requirements are satisfied. - Related open work: #13309 covers inherited secret restrictions; #11234 covers the broader repository catalog. This change does not replace either policy surface. ## Model Used OpenAI Codex (GPT-6), with code execution and browser tools. The precise runtime model ID and context-window size were not exposed in this session. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused server and full UI suites; full-suite infrastructure limit above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- 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) > 0 ? 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>
|
|
);
|
|
}
|