From a312bbf3d2d6fbff990d17707d727751683af5d9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 01:23:46 +0000 Subject: [PATCH] fix(ui): address UX review for Tools & Access (PAP-10389) - Add EnforcementBanner composite (CVA default/denied-detected) persistent at the top of the surface, wired to last-hour denial count; add to /design-guide. - Add New Connection dialog: secret-reference picker (no free-text token) + live gateway probe before activate. - Mobile tab nav: horizontal scroll strip instead of staggered wrap. - Route decision/health/quarantine pills through canonical StatusBadge with new tool-access status keys; consolidate all tabs onto the canonical components/EmptyState (with description); /design-guide entries added. - Runtime tab gets a live cyan dot when a slot is running. - ErrorState maps 404/route-not-found to a deploy-skew friendly message. Co-Authored-By: Paperclip --- ui/src/components/EmptyState.tsx | 7 +- ui/src/components/EnforcementBanner.tsx | 91 +++ ui/src/components/StatusBadge.tsx | 4 +- ui/src/lib/status-colors.ts | 15 + ui/src/pages/DesignGuide.tsx | 43 ++ ui/src/pages/tools/ApplicationsTab.tsx | 15 +- ui/src/pages/tools/AuditTab.tsx | 7 +- ui/src/pages/tools/ConnectionsTab.tsx | 344 ++++++++- ui/src/pages/tools/ExamplesTab.tsx | 217 +++++- ui/src/pages/tools/PoliciesTab.tsx | 701 ++++++++++++++++- ui/src/pages/tools/ProfilesTab.tsx | 960 +++++++++++++++++++++++- ui/src/pages/tools/RuntimeTab.tsx | 159 +++- ui/src/pages/tools/ToolsAccess.tsx | 33 +- ui/src/pages/tools/shared.tsx | 145 ++-- 14 files changed, 2578 insertions(+), 163 deletions(-) create mode 100644 ui/src/components/EnforcementBanner.tsx diff --git a/ui/src/components/EmptyState.tsx b/ui/src/components/EmptyState.tsx index 2e2612ab8fb..3cadb4536aa 100644 --- a/ui/src/components/EmptyState.tsx +++ b/ui/src/components/EmptyState.tsx @@ -5,17 +5,20 @@ import { Button } from "@/components/ui/button"; interface EmptyStateProps { icon: LucideIcon; message: string; + /** Optional secondary line rendered under the primary message. */ + description?: string; action?: string; onAction?: () => void; } -export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) { +export function EmptyState({ icon: Icon, message, description, action, onAction }: EmptyStateProps) { return (
-

{message}

+

{message}

+ {description &&

{description}

} {action && onAction && (
); } diff --git a/ui/src/pages/tools/ApplicationsTab.tsx b/ui/src/pages/tools/ApplicationsTab.tsx index a455164a5e4..e50785c2672 100644 --- a/ui/src/pages/tools/ApplicationsTab.tsx +++ b/ui/src/pages/tools/ApplicationsTab.tsx @@ -26,7 +26,8 @@ import { SelectValue, } from "@/components/ui/select"; import { useToast } from "@/context/ToastContext"; -import { ToolsPageHeader, LoadingState, ErrorState, EmptyState, RelativeTime } from "./shared"; +import { EmptyState } from "@/components/EmptyState"; +import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared"; const APP_TYPES: { value: ToolApplicationType; label: string }[] = [ { value: "mcp_http", label: "MCP server (remote HTTP)" }, @@ -92,15 +93,11 @@ export function ApplicationsTab({ companyId }: { companyId: string }) { {list.length === 0 ? ( } - title="No applications yet" + icon={AppWindow} + message="No applications yet" description="Register an MCP server or plugin tool bundle to start governing tool access." - action={ - - } + action="New application" + onAction={() => setOpen(true)} /> ) : (
diff --git a/ui/src/pages/tools/AuditTab.tsx b/ui/src/pages/tools/AuditTab.tsx index b95661ca272..9be0db36f21 100644 --- a/ui/src/pages/tools/AuditTab.tsx +++ b/ui/src/pages/tools/AuditTab.tsx @@ -14,7 +14,8 @@ import { } from "@/components/ui/select"; import { queryKeys } from "@/lib/queryKeys"; import { toolsApi, type ToolGatewayAuditRow } from "@/api/tools"; -import { ToolsPageHeader, LoadingState, ErrorState, EmptyState, RelativeTime } from "./shared"; +import { EmptyState } from "@/components/EmptyState"; +import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared"; const OUTCOME_FILTERS = [ { value: "__all", label: "All outcomes" }, @@ -136,8 +137,8 @@ export function AuditTab({ companyId }: { companyId: string }) { audit.refetch()} /> ) : filtered.length === 0 ? ( } - title="No matching audit events" + icon={ScrollText} + message="No matching audit events" description="Governed tool calls appear here as soon as agents start using the gateway." /> ) : ( diff --git a/ui/src/pages/tools/ConnectionsTab.tsx b/ui/src/pages/tools/ConnectionsTab.tsx index 500093af6b4..dae685f17e3 100644 --- a/ui/src/pages/tools/ConnectionsTab.tsx +++ b/ui/src/pages/tools/ConnectionsTab.tsx @@ -1,38 +1,45 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plug, RefreshCw, Stethoscope, ListTree } from "lucide-react"; -import type { ToolConnection } from "@paperclipai/shared"; +import { KeyRound, ListTree, Plug, Plus, RefreshCw, Stethoscope, Trash2 } from "lucide-react"; +import type { McpConnectionCredentialRef, ToolConnection } from "@paperclipai/shared"; import { queryKeys } from "@/lib/queryKeys"; -import { toolsApi } from "@/api/tools"; +import { toolsApi, type CreateToolConnectionInput } from "@/api/tools"; +import { secretsApi } from "@/api/secrets"; import { ApiError } from "@/api/client"; +import { EmptyState } from "@/components/EmptyState"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, + DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useToast } from "@/context/ToastContext"; import { ToolsPageHeader, LoadingState, ErrorState, - EmptyState, HealthBadge, RiskBadge, CapabilityBadges, + QuarantineBadge, RelativeTime, } from "./shared"; -function CatalogDialog({ - connection, - onClose, -}: { - connection: ToolConnection; - onClose: () => void; -}) { +function CatalogDialog({ connection, onClose }: { connection: ToolConnection; onClose: () => void }) { const catalog = useQuery({ queryKey: queryKeys.tools.catalog(connection.id), queryFn: () => toolsApi.listCatalog(connection.id), @@ -62,9 +69,7 @@ function CatalogDialog({ isWrite={entry.isWrite} isDestructive={entry.isDestructive} /> - {entry.status === "quarantined" ? ( - quarantined - ) : null} + {entry.status === "quarantined" ? : null} {entry.description ? (

{entry.description}

) : null} @@ -77,10 +82,304 @@ function CatalogDialog({ ); } +type CredentialDraft = { secretId: string; headerName: string }; + +/** + * New-connection dialog. Enforces secret *references* (no free-text token field) + * and runs a live gateway probe (health-check) against the draft before the + * operator activates it — per the Phase 0B spec surface map. + */ +function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClose: () => void }) { + const qc = useQueryClient(); + const { pushToast } = useToast(); + + const apps = useQuery({ + queryKey: queryKeys.tools.applications(companyId), + queryFn: () => toolsApi.listApplications(companyId), + }); + const secrets = useQuery({ + queryKey: queryKeys.secrets.list(companyId), + queryFn: () => secretsApi.list(companyId), + }); + const templates = useQuery({ + queryKey: queryKeys.tools.stdioTemplates(companyId), + queryFn: () => toolsApi.listStdioTemplates(companyId), + }); + + const [applicationId, setApplicationId] = useState(""); + const [name, setName] = useState(""); + const [transport, setTransport] = useState<"remote_http" | "local_stdio">("remote_http"); + const [endpointUrl, setEndpointUrl] = useState(""); + const [templateId, setTemplateId] = useState(""); + const [creds, setCreds] = useState([]); + const [pendingSecretId, setPendingSecretId] = useState(""); + const [pendingHeader, setPendingHeader] = useState("Authorization"); + const [draft, setDraft] = useState(null); + + const secretName = (id: string) => secrets.data?.find((s) => s.id === id)?.name ?? id.slice(0, 8); + + const credentialRefs: McpConnectionCredentialRef[] = useMemo( + () => + creds.map((c) => ({ + name: c.headerName, + secretId: c.secretId, + version: "latest", + placement: "header", + key: c.headerName, + })), + [creds], + ); + + const create = useMutation({ + mutationFn: () => { + const config: Record = + transport === "remote_http" ? { url: endpointUrl.trim() } : { templateId }; + const input: CreateToolConnectionInput = { + applicationId, + name: name.trim(), + transport, + status: "draft", + enabled: false, + config, + credentialRefs, + }; + return toolsApi.createConnection(companyId, input); + }, + onSuccess: (conn) => setDraft(conn), + onError: (err) => + pushToast({ + title: "Could not create connection", + body: err instanceof ApiError ? err.message : String(err), + tone: "error", + }), + }); + + const probe = useMutation({ + mutationFn: (id: string) => toolsApi.checkConnectionHealth(id), + onSuccess: (res) => setDraft(res.connection), + onError: (err) => + pushToast({ + title: "Probe failed", + body: err instanceof ApiError ? err.message : String(err), + tone: "error", + }), + }); + + const activate = useMutation({ + mutationFn: (id: string) => toolsApi.updateConnection(id, { status: "active", enabled: true }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }); + pushToast({ title: "Connection activated", tone: "success" }); + onClose(); + }, + onError: (err) => + pushToast({ + title: "Activation failed", + body: err instanceof ApiError ? err.message : String(err), + tone: "error", + }), + }); + + const addCred = () => { + if (!pendingSecretId || !pendingHeader.trim()) return; + setCreds((c) => [...c, { secretId: pendingSecretId, headerName: pendingHeader.trim() }]); + setPendingSecretId(""); + }; + + const transportConfigValid = + transport === "remote_http" ? endpointUrl.trim().length > 0 : templateId.length > 0; + const canCreate = !!applicationId && name.trim().length > 0 && transportConfigValid && !create.isPending; + + return ( + !o && onClose()}> + + + New connection + + Credentials are stored as secret references — Paperclip resolves them at gateway use time and never + exposes them to agents. The connection is created as a draft and probed before you activate it. + + + +
+
+ + +
+ +
+ + setName(e.target.value)} + placeholder="e.g. Production GitHub" + disabled={!!draft} + /> +
+ +
+ + +
+ + {transport === "remote_http" ? ( +
+ + setEndpointUrl(e.target.value)} + placeholder="https://mcp.example.com" + disabled={!!draft} + /> +
+ ) : ( +
+ + +

+ Only board-approved command templates can run. Arbitrary commands are never accepted. +

+
+ )} + + {/* Secret-reference picker — no free-text token field. */} +
+ + {creds.length > 0 ? ( +
    + {creds.map((c, i) => ( +
  • + + {c.headerName} + → secret {secretName(c.secretId)} + {!draft ? ( + + ) : null} +
  • + ))} +
+ ) : null} + {!draft ? ( +
+
+ +
+ setPendingHeader(e.target.value)} + placeholder="Header" + className="w-32" + aria-label="Header name" + /> + +
+ ) : null} +
+ + {draft ? ( +
+
+ Probe result + +
+ {draft.healthMessage ? ( +

{draft.healthMessage}

+ ) : null} + {draft.lastError ?

{draft.lastError}

: null} +
+ ) : null} +
+ + + + {!draft ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} + export function ConnectionsTab({ companyId }: { companyId: string }) { const qc = useQueryClient(); const { pushToast } = useToast(); const [catalogFor, setCatalogFor] = useState(null); + const [createOpen, setCreateOpen] = useState(false); const connections = useQuery({ queryKey: queryKeys.tools.connections(companyId), @@ -136,13 +435,21 @@ export function ConnectionsTab({ companyId }: { companyId: string }) { setCreateOpen(true)}> + + New connection + + } /> {list.length === 0 ? ( } - title="No connections yet" - description="Add a connection to an application to configure credentials and discover its tools. (Create + mcp.json import wire to POST /tools/connections and /tools/mcp/import-json.)" + icon={Plug} + message="No connections yet" + description="Add a connection to an application to configure credentials and discover its tools." + action="New connection" + onAction={() => setCreateOpen(true)} /> ) : (
@@ -195,6 +502,7 @@ export function ConnectionsTab({ companyId }: { companyId: string }) { )} {catalogFor ? setCatalogFor(null)} /> : null} + {createOpen ? setCreateOpen(false)} /> : null}
); } diff --git a/ui/src/pages/tools/ExamplesTab.tsx b/ui/src/pages/tools/ExamplesTab.tsx index 97796527c80..c35b94238a8 100644 --- a/ui/src/pages/tools/ExamplesTab.tsx +++ b/ui/src/pages/tools/ExamplesTab.tsx @@ -1,17 +1,218 @@ -import { ToolsPageHeader, PendingBackendNotice } from "./shared"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, CircleSlash, PackagePlus, PlayCircle, Sparkles, XCircle } from "lucide-react"; +import type { ToolExampleSmokeResult } from "@paperclipai/shared"; +import { queryKeys } from "@/lib/queryKeys"; +import { toolsApi } from "@/api/tools"; +import { ApiError } from "@/api/client"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { useToast } from "@/context/ToastContext"; +import { EmptyState } from "@/components/EmptyState"; +import { + CapabilityBadges, + DecisionBadge, + ErrorState, + LoadingState, + RiskBadge, + ToolsPageHeader, +} from "./shared"; + +function errorMessage(error: unknown) { + return error instanceof ApiError ? error.message : error instanceof Error ? error.message : String(error); +} + +function SmokeResult({ result }: { result: ToolExampleSmokeResult }) { + return ( +
+
+ {result.ok ? ( + + ) : ( + + )} + + Smoke {result.ok ? "passed" : "failed"} + + {result.actor.actorType} +
+
+ {result.checks.map((check) => ( +
+ {check.ok ? ( + + ) : ( + + )} + {check.name.replaceAll("_", " ")} + {check.toolName ? {check.toolName} : null} + {check.decision ? : null} + {check.reasonCode ? {check.reasonCode} : null} +
+ ))} +
+
+ ); +} + +export function ExamplesTab({ companyId }: { companyId: string }) { + const qc = useQueryClient(); + const { pushToast } = useToast(); + const [smokeResults, setSmokeResults] = useState>({}); + + const examples = useQuery({ + queryKey: queryKeys.tools.examples(companyId), + queryFn: () => toolsApi.listExamples(companyId), + }); + + const invalidateToolState = () => { + qc.invalidateQueries({ queryKey: queryKeys.tools.examples(companyId) }); + qc.invalidateQueries({ queryKey: queryKeys.tools.applications(companyId) }); + qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }); + qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeSlots(companyId) }); + qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeHealth(companyId) }); + qc.invalidateQueries({ queryKey: queryKeys.tools.audit(companyId, 100) }); + }; + + const install = useMutation({ + mutationFn: (exampleId: string) => toolsApi.installExample(companyId, exampleId), + onSuccess: (result) => { + invalidateToolState(); + pushToast({ + title: result.created ? "Example installed" : "Example already installed", + body: `${result.profileEntries.length} read-only tool grant(s) active`, + tone: "success", + }); + }, + onError: (error) => + pushToast({ + title: "Install failed", + body: errorMessage(error), + tone: "error", + }), + }); + + const smoke = useMutation({ + mutationFn: (exampleId: string) => toolsApi.smokeExample(companyId, exampleId), + onSuccess: (result) => { + setSmokeResults((current) => ({ ...current, [result.exampleId]: result })); + invalidateToolState(); + pushToast({ + title: result.ok ? "Smoke passed" : "Smoke failed", + body: `${result.checks.filter((check) => check.ok).length}/${result.checks.length} checks passed`, + tone: result.ok ? "success" : "error", + }); + }, + onError: (error) => + pushToast({ + title: "Smoke failed", + body: errorMessage(error), + tone: "error", + }), + }); + + if (examples.isLoading) return ; + if (examples.error) return examples.refetch()} />; + + const list = examples.data?.examples ?? []; -export function ExamplesTab() { return (
- + + {list.length === 0 ? ( + + ) : ( +
+ {list.map((example) => { + const installed = example.install.installed; + const result = smokeResults[example.id]; + return ( + + +
+ +
+
+ {example.title} + + {installed ? "installed" : "available"} + + {example.fixture.transport} +
+

{example.description}

+
+
+ + +
+
+ + {example.install.reason ? ( +
+ + {example.install.reason} +
+ ) : null} + +
+
+

Fixture tools

+
+ {example.fixture.tools.map((tool) => ( + + {tool.name} + + + + ))} +
+
+
+

Safe default profile

+
+
+ {example.safeDefaultProfile.name} + +
+

+ Allows {example.safeDefaultProfile.allowedToolNames.join(", ")} +

+
+
+
+ + {result ? : null} +
+
+ ); + })} +
+ )}
); } diff --git a/ui/src/pages/tools/PoliciesTab.tsx b/ui/src/pages/tools/PoliciesTab.tsx index e4b78b8725a..5d446cdd299 100644 --- a/ui/src/pages/tools/PoliciesTab.tsx +++ b/ui/src/pages/tools/PoliciesTab.tsx @@ -1,14 +1,33 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FlaskConical, Shield, ShieldX } from "lucide-react"; -import type { ToolAccessDecision } from "@paperclipai/shared"; +import { + FlaskConical, + Pencil, + Plus, + Shield, + ShieldAlert, + ShieldCheck, + ShieldX, + Trash2, +} from "lucide-react"; +import type { ToolAccessDecision, ToolPolicy, ToolPolicyType, ToolRiskLevel } from "@paperclipai/shared"; import { queryKeys } from "@/lib/queryKeys"; import { toolsApi } from "@/api/tools"; import { agentsApi } from "@/api/agents"; +import { projectsApi } from "@/api/projects"; import { ApiError } from "@/api/client"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -18,17 +37,213 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { useToast } from "@/context/ToastContext"; +import { EmptyState } from "@/components/EmptyState"; import { ToolsPageHeader, LoadingState, ErrorState, - EmptyState, DecisionBadge, RelativeTime, - PendingBackendNotice, } from "./shared"; +const ANY_VALUE = "__any__"; + +type BuilderPolicyType = Exclude; +type ActorTypeValue = "agent" | "user" | "system" | "plugin"; +type RateLimitKeyBy = "company" | "agent" | "application" | "connection" | "tool"; + +const POLICY_TYPES: Array<{ value: BuilderPolicyType; label: string }> = [ + { value: "allow", label: "Allow" }, + { value: "block", label: "Block" }, + { value: "require_approval", label: "Require approval" }, + { value: "rate_limit", label: "Rate limit" }, + { value: "redact", label: "Redact" }, + { value: "validate", label: "Validate" }, +]; + +const RISK_LEVELS: ToolRiskLevel[] = ["read", "write", "destructive", "low", "medium", "high", "critical"]; +const RATE_LIMIT_KEY_FIELDS: RateLimitKeyBy[] = ["company", "agent", "application", "connection", "tool"]; + +type PolicyFormState = { + id: string | null; + name: string; + description: string; + policyType: BuilderPolicyType; + priority: string; + enabled: boolean; + actorType: typeof ANY_VALUE | ActorTypeValue; + agentId: string; + projectId: string; + applicationId: string; + connectionId: string; + riskLevel: string; + toolNames: string; + rateLimitLimit: string; + rateLimitWindowSeconds: string; + rateLimitKeyBy: RateLimitKeyBy[]; + redactFields: string; + conditionsJson: string; + configJson: string; +}; + +function emptyPolicyForm(): PolicyFormState { + return { + id: null, + name: "", + description: "", + policyType: "allow", + priority: "100", + enabled: true, + actorType: ANY_VALUE, + agentId: ANY_VALUE, + projectId: ANY_VALUE, + applicationId: ANY_VALUE, + connectionId: ANY_VALUE, + riskLevel: ANY_VALUE, + toolNames: "", + rateLimitLimit: "60", + rateLimitWindowSeconds: "3600", + rateLimitKeyBy: ["agent", "tool"], + redactFields: "", + conditionsJson: "", + configJson: "", + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function stringList(value: unknown): string[] { + if (typeof value === "string" && value.trim()) return [value.trim()]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0); +} + +function parseList(value: string): string[] { + return value + .split(/[,\n]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function selectValue(selectors: Record, key: string) { + const value = selectors[key]; + return typeof value === "string" && value.trim() ? value : ANY_VALUE; +} + +function formatJson(value: unknown) { + return isRecord(value) && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : ""; +} + +function parseJsonObject(value: string, label: string): Record | null { + if (!value.trim()) return null; + const parsed = JSON.parse(value); + if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`); + return parsed; +} + +function policyTypeLabel(type: ToolPolicyType | string) { + return POLICY_TYPES.find((item) => item.value === type)?.label ?? type.replaceAll("_", " "); +} + +function policyIcon(type: ToolPolicyType) { + if (type === "allow") return ; + if (type === "block") return ; + if (type === "require_approval") return ; + return ; +} + +function policyToForm(policy: ToolPolicy): PolicyFormState { + const selectors = policy.selectors ?? {}; + const toolNames = [ + ...stringList(selectors.toolName), + ...stringList(selectors.toolNames), + ].join(", "); + const config = isRecord(policy.config) ? { ...policy.config } : {}; + const rawRateLimit = isRecord(config.rateLimit) ? config.rateLimit : config; + const rawRedact = isRecord(config.redact) ? config.redact : {}; + const rateLimitKeyBy = stringList(rawRateLimit.keyBy).filter((item): item is RateLimitKeyBy => + RATE_LIMIT_KEY_FIELDS.includes(item as RateLimitKeyBy), + ); + if (policy.policyType === "rate_limit") delete config.rateLimit; + if (policy.policyType === "redact") delete config.redact; + + return { + ...emptyPolicyForm(), + id: policy.id, + name: policy.name, + description: policy.description ?? "", + policyType: policy.policyType as BuilderPolicyType, + priority: String(policy.priority), + enabled: policy.enabled, + actorType: selectValue(selectors, "actorType") as PolicyFormState["actorType"], + agentId: selectValue(selectors, "agentId"), + projectId: selectValue(selectors, "projectId"), + applicationId: selectValue(selectors, "applicationId"), + connectionId: selectValue(selectors, "connectionId"), + riskLevel: selectValue(selectors, "riskLevel"), + toolNames, + rateLimitLimit: String(rawRateLimit.limit ?? "60"), + rateLimitWindowSeconds: String(rawRateLimit.windowSeconds ?? "3600"), + rateLimitKeyBy: rateLimitKeyBy.length > 0 ? rateLimitKeyBy : ["agent", "tool"], + redactFields: stringList(rawRedact.fields).join(", "), + conditionsJson: formatJson(policy.conditions), + configJson: formatJson(config), + }; +} + +function buildPolicyPayload(form: PolicyFormState) { + const priority = Number(form.priority); + if (!Number.isInteger(priority) || priority < 0 || priority > 10000) { + throw new Error("Priority must be an integer from 0 to 10000"); + } + const selectors: Record = {}; + if (form.actorType !== ANY_VALUE) selectors.actorType = form.actorType; + if (form.agentId !== ANY_VALUE) selectors.agentId = form.agentId; + if (form.projectId !== ANY_VALUE) selectors.projectId = form.projectId; + if (form.applicationId !== ANY_VALUE) selectors.applicationId = form.applicationId; + if (form.connectionId !== ANY_VALUE) selectors.connectionId = form.connectionId; + if (form.riskLevel !== ANY_VALUE) selectors.riskLevel = form.riskLevel; + const toolNames = parseList(form.toolNames); + if (toolNames.length === 1) selectors.toolName = toolNames[0]; + if (toolNames.length > 1) selectors.toolNames = toolNames; + + const conditions = parseJsonObject(form.conditionsJson, "Conditions JSON"); + const config = parseJsonObject(form.configJson, "Config JSON") ?? {}; + if (form.policyType === "rate_limit") { + const limit = Number(form.rateLimitLimit); + const windowSeconds = Number(form.rateLimitWindowSeconds); + if (!Number.isInteger(limit) || limit <= 0) throw new Error("Rate limit must be a positive integer"); + if (!Number.isInteger(windowSeconds) || windowSeconds <= 0) { + throw new Error("Rate limit window must be a positive number of seconds"); + } + config.rateLimit = { + limit, + windowSeconds, + keyBy: form.rateLimitKeyBy, + }; + } + if (form.policyType === "redact") { + const fields = parseList(form.redactFields); + if (fields.length > 0) config.redact = { fields }; + } + + return { + name: form.name.trim(), + description: form.description.trim() || null, + policyType: form.policyType, + priority, + enabled: form.enabled, + selectors, + conditions, + config: Object.keys(config).length > 0 ? config : null, + }; +} + function PolicySimulator({ companyId }: { companyId: string }) { const { pushToast } = useToast(); const [agentId, setAgentId] = useState(""); @@ -64,10 +279,6 @@ function PolicySimulator({ companyId }: { companyId: string }) { Decision simulator
-

- Evaluate what the policy engine would decide for an agent + tool, using authoritative server - state. This calls the same engine the gateway uses — it does not grant or change access. -

@@ -99,7 +310,7 @@ function PolicySimulator({ companyId }: { companyId: string }) { disabled={!agentId || !toolName.trim() || test.isPending} onClick={() => test.mutate()} > - {test.isPending ? "Evaluating…" : "Evaluate decision"} + {test.isPending ? "Evaluating..." : "Evaluate decision"} {result ? ( @@ -125,12 +336,87 @@ function PolicySimulator({ companyId }: { companyId: string }) { export function PoliciesTab({ companyId }: { companyId: string }) { const qc = useQueryClient(); const { pushToast } = useToast(); + const [form, setForm] = useState(null); + const policies = useQuery({ + queryKey: queryKeys.tools.policies(companyId), + queryFn: () => toolsApi.listPolicies(companyId), + }); + const agents = useQuery({ + queryKey: queryKeys.agents.list(companyId), + queryFn: () => agentsApi.list(companyId), + }); + const projects = useQuery({ + queryKey: queryKeys.projects.list(companyId), + queryFn: () => projectsApi.list(companyId), + }); + const applications = useQuery({ + queryKey: queryKeys.tools.applications(companyId), + queryFn: () => toolsApi.listApplications(companyId), + }); + const connections = useQuery({ + queryKey: queryKeys.tools.connections(companyId), + queryFn: () => toolsApi.listConnections(companyId), + }); const trustRules = useQuery({ queryKey: queryKeys.tools.trustRules(companyId), queryFn: () => toolsApi.listTrustRules(companyId), }); + const nameMaps = useMemo(() => ({ + agent: new Map((agents.data ?? []).map((item) => [item.id, item.name])), + project: new Map((projects.data ?? []).map((item) => [item.id, item.name])), + application: new Map((applications.data?.applications ?? []).map((item) => [item.id, item.name])), + connection: new Map((connections.data?.connections ?? []).map((item) => [item.id, item.name])), + }), [agents.data, applications.data, connections.data, projects.data]); + + const invalidatePolicies = () => qc.invalidateQueries({ queryKey: queryKeys.tools.policies(companyId) }); + + const createPolicy = useMutation({ + mutationFn: (input: ReturnType) => toolsApi.createPolicy(companyId, input), + onSuccess: () => { + invalidatePolicies(); + setForm(null); + pushToast({ title: "Policy created", tone: "success" }); + }, + onError: (err) => + pushToast({ + title: "Could not save policy", + body: err instanceof ApiError ? err.message : String(err), + tone: "error", + }), + }); + + const updatePolicy = useMutation({ + mutationFn: (input: { policyId: string; body: ReturnType }) => + toolsApi.updatePolicy(companyId, input.policyId, input.body), + onSuccess: () => { + invalidatePolicies(); + setForm(null); + pushToast({ title: "Policy updated", tone: "success" }); + }, + onError: (err) => + pushToast({ + title: "Could not save policy", + body: err instanceof ApiError ? err.message : String(err), + tone: "error", + }), + }); + + const deletePolicy = useMutation({ + mutationFn: (policyId: string) => toolsApi.deletePolicy(companyId, policyId), + onSuccess: () => { + invalidatePolicies(); + pushToast({ title: "Policy deleted", tone: "success" }); + }, + onError: (err) => + pushToast({ + title: "Delete failed", + body: err instanceof ApiError ? err.message : String(err), + tone: "error", + }), + }); + const revoke = useMutation({ mutationFn: (policyId: string) => toolsApi.revokeTrustRule(companyId, policyId), onSuccess: () => { @@ -145,21 +431,122 @@ export function PoliciesTab({ companyId }: { companyId: string }) { }), }); + function selectorSummary(policy: ToolPolicy) { + const selectors = policy.selectors ?? {}; + const parts: string[] = []; + const add = (label: string, key: string, names?: Map) => { + const values = [...stringList(selectors[key]), ...stringList(selectors[`${key}s`])]; + if (values.length > 0) { + parts.push(`${label}: ${values.map((value) => names?.get(value) ?? value).join(", ")}`); + } + }; + add("actor", "actorType"); + add("agent", "agentId", nameMaps.agent); + add("project", "projectId", nameMaps.project); + add("application", "applicationId", nameMaps.application); + add("connection", "connectionId", nameMaps.connection); + add("risk", "riskLevel"); + add("tool", "toolName"); + return parts.length > 0 ? parts.join(" · ") : "company-wide"; + } + + function submitPolicy() { + if (!form) return; + try { + const body = buildPolicyPayload(form); + if (!body.name) throw new Error("Name is required"); + if (form.id) { + updatePolicy.mutate({ policyId: form.id, body }); + } else { + createPolicy.mutate(body); + } + } catch (err) { + pushToast({ + title: "Invalid policy", + body: err instanceof Error ? err.message : String(err), + tone: "error", + }); + } + } + + const policyList = policies.data?.policies ?? []; + const saving = createPolicy.isPending || updatePolicy.isPending; + return (
setForm(emptyPolicyForm())}> + + New policy + + } /> +
+

+ + Policy rules +

+ {policies.isLoading ? ( + + ) : policies.error ? ( + policies.refetch()} /> + ) : policyList.length === 0 ? ( + setForm(emptyPolicyForm())} + /> + ) : ( +
+ {policyList.map((policy) => ( + + + {policyIcon(policy.policyType)} +
+
+ {policy.name} + {policyTypeLabel(policy.policyType)} + priority {policy.priority} + {!policy.enabled ? disabled : null} +
+

{selectorSummary(policy)}

+ {policy.description ? ( +

{policy.description}

+ ) : null} +
+ + + +
+ + +
+
+
+ ))} +
+ )} +
+ - -

@@ -171,9 +558,9 @@ export function PoliciesTab({ companyId }: { companyId: string }) { trustRules.refetch()} /> ) : (trustRules.data?.trustRules ?? []).length === 0 ? ( } - title="No trust rules" - description="Trust rules are created by promoting a repeated approved action into a scoped auto-allow. They appear here once created." + icon={Shield} + message="No trust rules" + description="Trust rules are created by promoting a repeated approved action into a scoped auto-allow." /> ) : (
@@ -181,7 +568,7 @@ export function PoliciesTab({ companyId }: { companyId: string }) { -
+
{rule.name} priority {rule.priority} @@ -191,7 +578,7 @@ export function PoliciesTab({ companyId }: { companyId: string }) {

{rule.description}

) : null}
- +
+ + !open && setForm(null)}> + {form ? ( + + + {form.id ? "Edit policy" : "New policy"} + Define the server-side rule and the selectors it applies to. + +
+
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="e.g. Block destructive shell tools" + /> +
+
+ + +
+
+ + setForm({ ...form, priority: e.target.value })} + /> +
+
+ +
+
+

Enabled

+

Disabled policies stay saved but do not match.

+
+ setForm({ ...form, enabled })} /> +
+ +
+ +