Files
PaperClipAI/ui/src/components/EnforcementBanner.tsx
T
DottaandPaperclip a116713b9a feat(mcp) [split 6/8]: add Tools and Profiles UI foundation (#9561)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Governed MCP access spans contracts, runtime enforcement, adapters,
UI surfaces, and operator verification
> - The parity reference PR #9534 is too large for effective automated
or human review
> - The feature therefore needs a linear stack whose individual diffs
stay below the 100-file review limit
> - This pull request is split 6/8 and focuses on UI API, shared
components, and Tools/Profile surfaces
> - The benefit is a standalone, testable review boundary while
preserving byte-for-byte parity at the top of the stack

## Linked Issues or Issue Description

- Related parity reference: #9534
- Problem: Operators need typed clients and administration surfaces that
compile independently before navigation exposes them.
- Proposed solution: Adds UI APIs, hooks, libraries, shared components,
Tools/Profiles pages, and the plugin settings consumer required by the
new company-scoped API.
- Alternatives considered: keeping #9534 as one 403-file review, or
rewriting the feature to manufacture seams; both were rejected in favor
of path extraction plus compile-driven boundary moves.
- Roadmap alignment: this advances the existing governed MCP/tool-access
work already represented by #9534; it does not introduce a separate
roadmap initiative.
- Stack position: base branch is
`pap10341-split/05-runtime-integration`.
- Merge policy: merge bottom-up, in order, only after the complete
eight-PR stack has been reviewed and the top-of-stack parity gate
remains empty.
- Requested review: UXDesigner sanity pass on Tools/Profile surfaces;
Greptile on every PR.

## What Changed

- Adds UI APIs, hooks, libraries, shared components, Tools/Profiles
pages, and the plugin settings consumer required by the new
company-scoped API.
- Keeps this PR below 100 changed files and independently typecheckable.
- Preserves the final tree from #9534 when combined with the other seven
stack levels.

## Verification

- `pnpm typecheck`
- `pnpm check:token-gates` — all gates clean
- Focused UI Vitest run with `NODE_ENV=test` — 28 files, 183 tests
passed

## Risks

- Large dead-code UI additions can drift from activation routes; PR 7
supplies the registration layer and top-level parity catches omissions.
- Stack risk: merging out of order can expose incomplete layers;
mitigate by following the documented bottom-up merge policy.
- Parity risk: later edits to an intermediate branch can drift from
#9534; mitigate by re-running the empty top-of-stack diff before merge.

> 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, exact model ID `gpt-5.4`; runtime-managed context
window; medium reasoning with repository, shell, Git, GitHub CLI, and
code-execution tools enabled.

## 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
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] Internal references are omitted except the execution-plan link
explicitly required for this coordinated split stack
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


## Stack Coordination

- Internal execution plan:
[PAP-13874](/PAP/issues/PAP-13874#document-plan)
- Parity reference: #9534
- Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563
- Merge bottom-up only after full-stack review and an empty parity diff
at #9563.


## UI Evidence

QA captured these from the live Garden MCP split stack at 1440px and
verified clean rendering:

![Profiles
index](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/profiles-index.png)

![Profile
wizard](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/profile-wizard.png)


![Policies](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/policies.png)

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-14 15:31:12 -05:00

176 lines
6.0 KiB
TypeScript

import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { cva, type VariantProps } from "class-variance-authority";
import { ShieldAlert, ShieldCheck, type LucideIcon } from "lucide-react";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
/**
* Persistent enforcement-state banner for the Tools & Access surface (PAP-10389).
*
* Two modes:
*
* 1. **Data-driven** (default) — pass only `companyId`. Renders the standing
* "enforcement is server-side" message and tints to `denied-detected` when
* governed tool calls were denied or failed in the last hour. This is an
* *observability* banner — enforcement itself lives in the tool gateway.
*
* 2. **Presentational** (`tone` + `title`/`body`) — a static governance banner
* used to surface a fixed message such as the PAP-10400 trust-tier copy on
* the Runtime tab. Tones map to the same OKLCH token palette used elsewhere:
* `info` (neutral/shield), `warning` (amber), `error` (destructive).
*/
const enforcementBanner = cva(
"flex items-start gap-2.5 rounded-lg border px-4 py-3 text-sm",
{
variants: {
variant: {
default: "border-border bg-muted/40 text-muted-foreground",
"denied-detected":
"border-amber-500/40 bg-amber-50 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
info: "border-border bg-muted/40 text-muted-foreground",
warning:
"border-amber-500/40 bg-amber-50 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
error:
"border-destructive/40 bg-destructive/5 text-destructive dark:text-destructive",
},
},
defaultVariants: { variant: "default" },
},
);
const DENY_ACTIONS = new Set(["tool_gateway.call_denied", "tool_gateway.call_failed"]);
const ONE_HOUR_MS = 60 * 60 * 1000;
export type EnforcementTone = "info" | "warning" | "error";
export interface EnforcementBannerProps extends VariantProps<typeof enforcementBanner> {
companyId?: string;
className?: string;
/** Override the computed variant (used by the design guide). */
forceVariant?: "default" | "denied-detected";
recentDenialCount?: number;
/**
* Presentational mode: when provided, the banner renders a static governance
* message with this tone instead of the data-driven denial summary.
*/
tone?: EnforcementTone;
/** Presentational title (bold first line). */
title?: ReactNode;
/** Presentational body copy. */
body?: ReactNode;
/** Override the leading icon (presentational mode). */
icon?: LucideIcon;
/** Optional trailing action node (presentational mode). */
action?: ReactNode;
}
function PresentationalBanner({
tone,
title,
body,
icon,
action,
className,
}: {
tone: EnforcementTone;
title?: ReactNode;
body?: ReactNode;
icon?: LucideIcon;
action?: ReactNode;
className?: string;
}) {
const Icon = icon ?? (tone === "info" ? ShieldCheck : ShieldAlert);
const iconTone =
tone === "info"
? "text-emerald-600 dark:text-emerald-400"
: tone === "warning"
? "text-amber-600 dark:text-amber-400"
: "text-destructive";
return (
<div className={cn(enforcementBanner({ variant: tone }), className)} role="status">
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", iconTone)} />
<div className="min-w-0 flex-1 space-y-0.5">
{title ? <p className="font-medium">{title}</p> : null}
{body ? <p className="opacity-90">{body}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}
export function EnforcementBanner(props: EnforcementBannerProps) {
const { companyId, className, forceVariant, recentDenialCount, tone, title, body, icon, action } = props;
// Presentational mode short-circuits the data hook below.
const isPresentational = tone !== undefined;
const audit = useQuery({
queryKey: queryKeys.tools.audit(companyId ?? "", 100),
queryFn: () => toolsApi.listAudit(companyId ?? "", 100),
enabled:
!isPresentational &&
forceVariant === undefined &&
recentDenialCount === undefined &&
!!companyId,
refetchInterval: 30_000,
});
if (isPresentational) {
return (
<PresentationalBanner
tone={tone}
title={title}
body={body}
icon={icon}
action={action}
className={className}
/>
);
}
const computedCount =
recentDenialCount ??
(audit.data ?? []).filter((row) => {
if (!DENY_ACTIONS.has(row.action)) return false;
const ts = new Date(row.createdAt).getTime();
return Number.isFinite(ts) && Date.now() - ts <= ONE_HOUR_MS;
}).length;
const variant: "default" | "denied-detected" =
forceVariant ?? (computedCount > 0 ? "denied-detected" : "default");
return (
<div className={cn(enforcementBanner({ variant }), className)} role="status">
{variant === "denied-detected" ? (
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0 flex-1">
{variant === "denied-detected" ? (
<p>
<span className="font-medium">{computedCount}</span> governed tool call
{computedCount === 1 ? " was" : "s were"} denied or failed in the last hour. Access is enforced
server-side by the tool gateway — review what was blocked and why in the audit log.
</p>
) : (
<p>
Tool access is enforced server-side by the tool gateway. These screens configure and observe that
enforcement — they do not replace it. Agents see and call only the tools their profiles and policies
allow; everything else is denied by default.
</p>
)}
</div>
<Link
to="/apps/advanced/audit"
className="shrink-0 text-xs font-medium text-primary hover:underline"
>
View audit →
</Link>
</div>
);
}