{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-permissions",
  "title": "Agent Permissions",
  "description": "A capability-scoping panel for one agent — per-tool switches with risk bands, a spend cap metered against what is already spent, an add-and-remove domain allowlist, an ordered file-access scale with policy ceilings, and an unsaved-changes bar that counts every change that expands the agent's authority.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/agent-permissions.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ArrowDown,\n  ArrowUp,\n  CircleAlert,\n  Coins,\n  FolderOpen,\n  Globe,\n  LoaderCircle,\n  Lock,\n  Plus,\n  RefreshCcw,\n  ShieldAlert,\n  ShieldCheck,\n  ShieldOff,\n  TriangleAlert,\n  Undo2,\n  Wrench,\n  X,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  AGENT_FILE_ACCESS_SCOPES,\n  clampFileAccessScope,\n  clampSpendCap,\n  domainHostError,\n  fileAccessScopeIndex,\n  normalizeDomainHost,\n  type AgentDomain,\n  type AgentFileAccess,\n  type AgentFileAccessScope,\n  type AgentPermissionChange,\n  type AgentPermissionRisk,\n  type AgentPermissionTool,\n  type AgentPermissionsDraft,\n  type AgentPermissionsStatus,\n  type AgentSpendCap,\n} from \"./agent-permissions.contract\"\n\n/* -------------------------------------------------------------------------- *\n * Agent Permissions\n *\n * A capability-scoping panel for one agent. Four unlike controls (switches, a\n * money field, a chip set, an ordered scale) held together by three ideas:\n *\n * 1. The draft is a SPARSE OVERLAY on the saved data — a key exists only while\n *    it differs, so \"changed\" is derived rather than tracked and the object is\n *    already the PATCH body.\n * 2. Every edit has a DIRECTION. Expanding what an agent may do is the move\n *    worth naming, and the panel names it per row and again in the save bar.\n * 3. A policy limit is a constraint on the CONTROL, not a validation message\n *    afterwards: unreachable rungs are unreachable by pointer and by keyboard,\n *    a cap above the plan ceiling is clamped as you type, and a saved value that\n *    sits outside the limits arrives already staged for the correction.\n * -------------------------------------------------------------------------- */\n\nconst KEYFRAMES = `@keyframes agp-rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}`\nconst RISE = \"[animation:agp-rise_160ms_ease-out] motion-reduce:[animation:none]\"\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\nconst CHIP = \"shrink-0 rounded-full border px-1.5 py-px text-[10px] font-normal uppercase tracking-wide\"\n\n/** Which blocks the panel draws, in this order. */\nexport const AGENT_PERMISSION_SECTIONS = [\"tools\", \"spend\", \"domains\", \"files\"] as const\nexport type AgentPermissionsSection = (typeof AGENT_PERMISSION_SECTIONS)[number]\n\n/**\n * Risk is rendered as a WORD plus a tone, never as a tone alone: \"high\" has to\n * survive a monochrome theme and a colour-blind reader, and a red pill with no\n * text says nothing to a screen reader.\n */\nconst RISK_META: Record<AgentPermissionRisk, { label: string; className: string }> = {\n  low: { label: \"Low risk\", className: \"text-muted-foreground\" },\n  medium: { label: \"Medium risk\", className: \"border-primary/40 bg-primary/10 text-foreground\" },\n  high: { label: \"High risk\", className: \"border-destructive/40 bg-destructive/10 text-destructive\" },\n}\n\n/** Wording for one rung of the file-access scale. */\nexport interface AgentFileAccessMeta {\n  label: string\n  /** What this rung actually permits, in plain language. Becomes the radio's description. */\n  effect: string\n}\n\n/**\n * Default wording. Each rung is phrased as what the AGENT may do, never as a\n * quality judgement, so the same scale reads correctly for a code assistant and\n * for a document summariser.\n */\nexport const AGENT_FILE_ACCESS_META: Record<AgentFileAccessScope, AgentFileAccessMeta> = {\n  none: {\n    label: \"No file access\",\n    effect: \"The agent can't open, create or change any file — it only sees what you paste into the conversation.\",\n  },\n  read: {\n    label: \"Read only\",\n    effect: \"The agent can open and read files, but never writes one back.\",\n  },\n  \"read-write\": {\n    label: \"Read and write\",\n    effect: \"The agent can create, edit and delete files, including changes you didn't ask for.\",\n  },\n}\n\nconst countFormatter = new Intl.NumberFormat(\"en-US\")\n\n/**\n * One formatter per currency, built lazily. An unknown ISO code makes\n * `Intl.NumberFormat` throw, and a settings panel must not go down because a\n * back end sent \"BTC\" — the fallback prints the number and the code.\n */\nconst moneyFormatters = new Map<string, (amount: number) => string>()\nfunction defaultFormatMoney(amount: number, currency: string): string {\n  let format = moneyFormatters.get(currency)\n  if (!format) {\n    try {\n      const nf = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, maximumFractionDigits: 2 })\n      format = (value: number) => nf.format(value)\n    } catch {\n      const nf = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 })\n      format = (value: number) => `${nf.format(value)} ${currency}`\n    }\n    moneyFormatters.set(currency, format)\n  }\n  return format(amount)\n}\n\n/**\n * `null` for anything that is not a non-negative amount. Tolerates what people\n * actually type into a money field (\"$1,200\", \" 40 \") but refuses to guess at\n * \"4o\" — a silently coerced 0 would be a budget of nothing.\n */\nfunction parseAmount(text: string): number | null {\n  const cleaned = text.trim().replace(/[$,\\s]/g, \"\")\n  if (cleaned === \"\" || !/^\\d*\\.?\\d*$/.test(cleaned)) return null\n  const value = Number(cleaned)\n  if (!Number.isFinite(value) || value < 0) return null\n  return Math.round(value * 100) / 100\n}\n\n/** Canonical text for an amount: integers stay integers, the rest get cents. */\nfunction amountToText(value: number): string {\n  return Number.isInteger(value) ? String(value) : value.toFixed(2)\n}\n\n/* --------------------------------------------------------------- primitives */\n\n/**\n * A real `role=\"switch\"`, not a checkbox and not a two-state radio: a tool grant\n * is one thing that is on or off, and `aria-checked` on a switch is the only\n * semantics that reads as \"allowed / blocked\" rather than \"selected\".\n *\n * `aria-disabled`, never the native attribute: `disabled` drops focus to <body>\n * the instant a row is locked under the user's cursor and takes the control out\n * of the tab order while it is still worth reading.\n */\nfunction PermissionSwitch({\n  checked,\n  describedBy,\n  disabled,\n  label,\n  onToggle,\n}: {\n  checked: boolean\n  describedBy?: string\n  disabled: boolean\n  label: string\n  onToggle: () => void\n}) {\n  return (\n    <button\n      aria-checked={checked}\n      aria-describedby={describedBy}\n      aria-disabled={disabled || undefined}\n      aria-label={label}\n      className={cn(\n        \"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors motion-reduce:transition-none\",\n        FOCUS,\n        checked ? \"border-primary bg-primary\" : \"bg-muted\",\n        disabled ? \"cursor-not-allowed opacity-60\" : \"cursor-pointer\",\n      )}\n      data-checked={checked || undefined}\n      onClick={() => {\n        if (disabled) return\n        onToggle()\n      }}\n      role=\"switch\"\n      type=\"button\"\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"size-4 rounded-full border bg-background transition-transform duration-150 motion-reduce:transition-none\",\n          checked ? \"translate-x-4\" : \"translate-x-0.5\",\n        )}\n      />\n    </button>\n  )\n}\n\nfunction SectionHeading({\n  action,\n  icon,\n  id,\n  note,\n  title,\n}: {\n  action?: React.ReactNode\n  icon: React.ReactNode\n  id: string\n  note?: React.ReactNode\n  title: string\n}) {\n  return (\n    <div className=\"flex min-w-0 flex-wrap items-start justify-between gap-x-3 gap-y-1\">\n      <div className=\"flex min-w-0 flex-col gap-0.5\">\n        <h4 className=\"flex min-w-0 items-center gap-2 font-medium\" id={id}>\n          {icon}\n          <span className=\"min-w-0 wrap-anywhere\">{title}</span>\n        </h4>\n        {note && <p className=\"min-w-0 wrap-anywhere text-xs text-muted-foreground\">{note}</p>}\n      </div>\n      {action}\n    </div>\n  )\n}\n\n/* ----------------------------------------------------------------- tool row */\n\ninterface ToolRowProps {\n  tool: AgentPermissionTool\n  /** Draft grant — what the row currently shows. */\n  allowed: boolean\n  disabled: boolean\n  onToggle: () => void\n}\n\nfunction ToolRow({ allowed, disabled, onToggle, tool }: ToolRowProps) {\n  const uid = React.useId()\n  const lockId = `${uid}-lock`\n  const warnId = `${uid}-warn`\n\n  const locked = tool.locked === true\n  const dirty = allowed !== tool.allowed\n  const risk = RISK_META[tool.risk]\n  const heading = tool.label ?? tool.name\n\n  const Icon = locked ? Lock : allowed ? (tool.risk === \"high\" ? ShieldAlert : ShieldCheck) : ShieldOff\n  const iconTone = locked\n    ? \"text-muted-foreground\"\n    : allowed\n      ? tool.risk === \"high\"\n        ? \"text-destructive\"\n        : \"text-primary\"\n      : \"text-muted-foreground\"\n\n  const lockNote = locked ? tool.lockedReason : undefined\n  // Only while it is being turned ON, and only for the band that earns the noise:\n  // a warning printed next to every switch is a warning nobody reads.\n  const warnNote =\n    !locked && dirty && allowed && tool.risk === \"high\"\n      ? (tool.warning ??\n        \"This tool can change things outside the conversation. Grant it only for as long as you need it.\")\n      : undefined\n\n  const describedBy = [lockNote ? lockId : null, warnNote ? warnId : null].filter(Boolean).join(\" \") || undefined\n\n  return (\n    <div\n      className=\"flex min-w-0 flex-col gap-1.5 px-4 py-3\"\n      data-allowed={allowed || undefined}\n      data-dirty={dirty || undefined}\n      data-tool={tool.id}\n    >\n      <div className=\"flex min-w-0 items-start gap-3\">\n        <Icon aria-hidden=\"true\" className={cn(\"mt-0.5 size-4 shrink-0\", iconTone)} />\n\n        <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n          <span className=\"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1\">\n            {/* wrap-anywhere + min-w-0: a 70-character tool identifier wraps inside\n                the row instead of pushing the whole panel wider than its container. */}\n            <span className=\"min-w-0 wrap-anywhere font-medium\">{heading}</span>\n            <span className={cn(CHIP, risk.className)}>{risk.label}</span>\n            {locked && <span className={cn(CHIP, \"text-muted-foreground\")}>Locked</span>}\n          </span>\n          <span className=\"min-w-0 wrap-anywhere font-mono text-xs text-muted-foreground\">{tool.name}</span>\n          {tool.description && (\n            <span className=\"min-w-0 wrap-anywhere text-xs text-muted-foreground\">{tool.description}</span>\n          )}\n        </div>\n\n        <span className=\"flex shrink-0 items-center gap-2\">\n          {dirty ? (\n            <span className=\"flex items-center gap-1 text-xs\" data-change=\"\">\n              {/* Direction is a SHAPE as well as a tone. Up = the agent gains something. */}\n              {allowed ? (\n                <ArrowUp aria-hidden=\"true\" className=\"size-3.5 text-destructive\" />\n              ) : (\n                <ArrowDown aria-hidden=\"true\" className=\"size-3.5 text-primary\" />\n              )}\n              <span className={cn(allowed ? \"text-destructive\" : \"text-muted-foreground\")}>\n                was {tool.allowed ? \"allowed\" : \"blocked\"}\n              </span>\n            </span>\n          ) : (\n            tool.calls !== undefined && (\n              // Provenance, only while the row is clean: once it is dirty, how often\n              // it ran is stale trivia and the pending change is the news.\n              <span className=\"text-xs text-muted-foreground tabular-nums\" data-provenance=\"\">\n                {countFormatter.format(tool.calls)} calls\n              </span>\n            )\n          )}\n          <PermissionSwitch\n            checked={allowed}\n            describedBy={describedBy}\n            disabled={disabled || locked}\n            label={`Allow ${heading}`}\n            onToggle={onToggle}\n          />\n        </span>\n      </div>\n\n      {lockNote && (\n        <p className=\"min-w-0 wrap-anywhere pl-7 text-xs text-muted-foreground\" id={lockId}>\n          {lockNote}\n        </p>\n      )}\n      {warnNote && (\n        <p className=\"flex min-w-0 items-start gap-1.5 pl-7 text-xs text-destructive\" data-warning=\"\" id={warnId}>\n          <TriangleAlert aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n          <span className=\"min-w-0 wrap-anywhere\">{warnNote}</span>\n        </p>\n      )}\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------- domain chip */\n\ninterface DomainChipProps {\n  host: string\n  locked: boolean\n  note?: string\n  /** Staged for removal: still on screen, struck through, one click from coming back. */\n  removed: boolean\n  /** Not on the saved list yet. */\n  added: boolean\n  flash: boolean\n  disabled: boolean\n  onRemove: () => void\n  onRestore: () => void\n}\n\nfunction DomainChip({ added, disabled, flash, host, locked, note, onRemove, onRestore, removed }: DomainChipProps) {\n  const action = removed ? onRestore : onRemove\n  return (\n    <li className=\"min-w-0\">\n      <span\n        className={cn(\n          \"inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors motion-reduce:transition-none\",\n          added && !removed && \"border-primary/50 bg-primary/5\",\n          removed && \"border-destructive/40 text-muted-foreground line-through\",\n          flash && \"ring-2 ring-ring\",\n        )}\n        data-added={added || undefined}\n        data-host={host}\n        data-removed={removed || undefined}\n        title={note}\n      >\n        {locked && <Lock aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" />}\n        <span className=\"min-w-0 wrap-anywhere\">{host}</span>\n        {!locked && (\n          <button\n            aria-disabled={disabled || undefined}\n            aria-label={removed ? `Keep ${host} on the allowlist` : `Remove ${host} from the allowlist`}\n            className={cn(\n              \"-mr-1 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground motion-reduce:transition-none\",\n              FOCUS,\n              disabled ? \"cursor-not-allowed opacity-60\" : \"cursor-pointer\",\n            )}\n            data-domain-action=\"\"\n            onClick={() => {\n              if (disabled) return\n              action()\n            }}\n            type=\"button\"\n          >\n            {removed ? (\n              <Undo2 aria-hidden=\"true\" className=\"size-3\" />\n            ) : (\n              <X aria-hidden=\"true\" className=\"size-3\" />\n            )}\n          </button>\n        )}\n      </span>\n    </li>\n  )\n}\n\n/* --------------------------------------------------------- file access scale */\n\ninterface ScopeChoiceProps {\n  labelledBy: string\n  describedBy?: string\n  value: AgentFileAccessScope\n  /** Rungs above this one are unreachable. */\n  max?: AgentFileAccessScope\n  disabled: boolean\n  metaOf: (scope: AgentFileAccessScope) => AgentFileAccessMeta\n  effectOf: (scope: AgentFileAccessScope) => string\n  onSelect: (scope: AgentFileAccessScope) => void\n}\n\n/**\n * Three exclusive, ORDINAL rungs — a real `radiogroup`: arrow keys traverse it,\n * selection follows focus, and the whole group is one Tab stop (roving\n * tabindex). Rungs above the policy ceiling are not in the traversal order at\n * all, so there is no \"press it and get told off\" path.\n */\nfunction ScopeChoice({\n  describedBy,\n  disabled,\n  effectOf,\n  labelledBy,\n  max,\n  metaOf,\n  onSelect,\n  value,\n}: ScopeChoiceProps) {\n  const refs = React.useRef(new Map<AgentFileAccessScope, HTMLButtonElement>())\n\n  const setRef = (scope: AgentFileAccessScope) => (node: HTMLButtonElement | null) => {\n    if (node) refs.current.set(scope, node)\n    else refs.current.delete(scope)\n  }\n\n  const maxIndex = max === undefined ? AGENT_FILE_ACCESS_SCOPES.length - 1 : fileAccessScopeIndex(max)\n  const selectable: AgentFileAccessScope[] = disabled\n    ? []\n    : AGENT_FILE_ACCESS_SCOPES.filter(scope => fileAccessScopeIndex(scope) <= maxIndex)\n  // A disabled group still exposes its selected rung, so a keyboard reader can\n  // find out what is set without being able to change it.\n  const focusTarget: AgentFileAccessScope = selectable.includes(value) ? value : (selectable[0] ?? value)\n\n  const moveTo = (index: number) => {\n    const next = selectable[index]\n    refs.current.get(next)?.focus()\n    // Selection follows focus, per the radiogroup pattern: arrowing IS choosing.\n    if (next !== value) onSelect(next)\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled || selectable.length === 0) return\n    const current = selectable.indexOf(value)\n    const last = selectable.length - 1\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowRight\") {\n      moveTo(current === -1 ? 0 : (current + 1) % selectable.length)\n    } else if (event.key === \"ArrowUp\" || event.key === \"ArrowLeft\") {\n      moveTo(current === -1 ? last : (current - 1 + selectable.length) % selectable.length)\n    } else if (event.key === \"Home\") {\n      moveTo(0)\n    } else if (event.key === \"End\") {\n      moveTo(last)\n    } else {\n      return\n    }\n    event.preventDefault()\n  }\n\n  return (\n    <div\n      aria-describedby={describedBy}\n      aria-disabled={disabled || undefined}\n      aria-labelledby={labelledBy}\n      className=\"flex min-w-0 flex-col gap-1\"\n      data-scope-choice=\"\"\n      onKeyDown={handleKeyDown}\n      role=\"radiogroup\"\n    >\n      {AGENT_FILE_ACCESS_SCOPES.map(scope => {\n        const blocked = fileAccessScopeIndex(scope) > maxIndex\n        const selected = scope === value\n        const meta = metaOf(scope)\n        return (\n          <button\n            aria-checked={selected}\n            aria-disabled={disabled || blocked || undefined}\n            className={cn(\n              \"flex min-w-0 items-start gap-2.5 rounded-md border p-2.5 text-left transition-colors motion-reduce:transition-none\",\n              FOCUS,\n              selected ? \"border-primary bg-primary/5\" : \"border-transparent\",\n              !disabled && !blocked && !selected && \"cursor-pointer hover:bg-muted\",\n              (disabled || blocked) && \"cursor-not-allowed\",\n            )}\n            data-scope={scope}\n            key={scope}\n            onClick={() => {\n              if (disabled || blocked || selected) return\n              onSelect(scope)\n            }}\n            ref={setRef(scope)}\n            role=\"radio\"\n            tabIndex={focusTarget === scope ? 0 : -1}\n            type=\"button\"\n          >\n            <span\n              aria-hidden=\"true\"\n              className={cn(\n                \"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border\",\n                selected && \"border-primary\",\n                blocked && \"opacity-50\",\n              )}\n            >\n              {selected && <span className=\"size-2 rounded-full bg-primary\" />}\n            </span>\n            <span className=\"flex min-w-0 flex-col gap-0.5\">\n              <span\n                className={cn(\n                  \"min-w-0 wrap-anywhere font-medium\",\n                  blocked && \"text-muted-foreground/70 line-through\",\n                )}\n              >\n                {meta.label}\n              </span>\n              <span className=\"min-w-0 wrap-anywhere text-xs text-muted-foreground\">{effectOf(scope)}</span>\n            </span>\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n\n/* ------------------------------------------------------------------- panel */\n\nexport interface AgentPermissionsProps extends Omit<React.HTMLAttributes<HTMLElement>, \"onChange\"> {\n  /** Envelope state — whether there is a permission set to edit at all. */\n  status: AgentPermissionsStatus\n  /** The saved capabilities. `allowed` on each one is the baseline the draft is diffed against. */\n  tools: AgentPermissionTool[]\n  /** The saved budget, or `null` for a deployment that meters nothing (the block disappears). */\n  spend: AgentSpendCap | null\n  /** The saved network allowlist. */\n  domains: AgentDomain[]\n  /** The saved file authority, its root and its policy ceiling. */\n  fileAccess: AgentFileAccess\n  /**\n   * Controlled draft overlay (sparse: only what differs from the saved values).\n   * Omit it and the panel keeps the overlay itself.\n   */\n  value?: AgentPermissionsDraft\n  /** Fires on every edit with the NORMALISED patch and the derived change list. */\n  onValueChange?: (next: AgentPermissionsDraft, changes: AgentPermissionChange[]) => void\n  /** Fires at most once per attempt. Renders the Save button; omit it and there is none. */\n  onSave?: (next: AgentPermissionsDraft, changes: AgentPermissionChange[]) => void\n  /** Called after the draft is cleared by \"Discard\". */\n  onDiscard?: () => void\n  /** A write is in flight. Locks the bar; falling back to false re-arms Save. */\n  saving?: boolean\n  /** Read-only: every control is aria-disabled but still readable and reachable. */\n  disabled?: boolean\n  /** Which blocks to draw, in this order. Default: all four. */\n  sections?: readonly AgentPermissionsSection[]\n  /** Hard ceiling on the allowlist length. Default 20. */\n  maxDomains?: number\n  /** Extra host rule on top of the built-in syntax check. Return a message to reject. */\n  validateDomain?: (host: string) => string | undefined\n  /** Override the money wording. Default: `Intl.NumberFormat` with the entry's currency. */\n  formatMoney?: (amount: number, currency: string) => string\n  /** Re-word any rung of the file-access scale. */\n  fileAccessMeta?: Partial<Record<AgentFileAccessScope, Partial<AgentFileAccessMeta>>>\n  /** Panel heading. `null` drops the header block and names the region with `label`. */\n  heading?: React.ReactNode\n  description?: React.ReactNode\n  domainPlaceholder?: string\n  /** Replaces the default `status=\"empty\"` body. */\n  emptyState?: React.ReactNode\n  /** Message shown in the `status=\"error\"` branch. */\n  errorMessage?: string\n  /** Renders \"Try again\" in the error branch; omit it to hide the affordance. */\n  onRetry?: () => void\n  /** Accessible name for the region when no heading is rendered. */\n  label?: string\n  saveLabel?: string\n}\n\n/**\n * What one agent is allowed to do: per-tool grants with a risk band, a spend cap\n * metered against what has already been spent, a network allowlist, and an\n * ordered file-access scale — all staged into one unsaved-changes bar that says\n * how much of the change EXPANDS the agent's authority.\n */\nexport const AgentPermissions = React.forwardRef<HTMLElement, AgentPermissionsProps>(\n  (\n    {\n      status,\n      tools,\n      spend,\n      domains,\n      fileAccess,\n      value,\n      onValueChange,\n      onSave,\n      onDiscard,\n      saving,\n      disabled = false,\n      sections = AGENT_PERMISSION_SECTIONS,\n      maxDomains = 20,\n      validateDomain,\n      formatMoney = defaultFormatMoney,\n      fileAccessMeta,\n      heading = \"Agent permissions\",\n      description = \"What this agent may do on your behalf, and how far it may go.\",\n      domainPlaceholder = \"api.example.com or *.example.com\",\n      emptyState,\n      errorMessage = \"These permissions couldn't be loaded.\",\n      onRetry,\n      label = \"Agent permissions\",\n      saveLabel = \"Save permissions\",\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const uid = React.useId()\n    const headingId = `${uid}-heading`\n    const toolsId = `${uid}-tools`\n    const spendId = `${uid}-spend`\n    const capId = `${uid}-cap`\n    const capNoteId = `${uid}-cap-note`\n    const domainsId = `${uid}-domains`\n    const domainInputId = `${uid}-domain-input`\n    const domainErrorId = `${uid}-domain-error`\n    const filesId = `${uid}-files`\n    const filesNoteId = `${uid}-files-note`\n\n    const savingNow = saving === true\n    const currency = spend?.currency ?? \"USD\"\n    const money = (amount: number) => formatMoney(amount, currency)\n\n    /**\n     * Saved hosts, canonicalised and de-duplicated once: allowlist equality is SET\n     * equality, and a back end that stored both `Example.com` and `example.com`\n     * must not produce two chips with the same React key.\n     */\n    const savedHosts = Array.from(\n      new Set(domains.map(domain => normalizeDomainHost(domain.host)).filter(host => host !== \"\")),\n    )\n\n    /**\n     * Identity of the SAVED data. `usedUsd` is deliberately ABSENT: the meter\n     * ticks on its own while the panel is open, and a tick must never wipe out an\n     * edit in progress. Limits ARE included — a policy tightened server-side is a\n     * new baseline and the draft has to be re-derived against it.\n     */\n    const baselineToken = [\n      tools.map(tool => `${tool.id}:${tool.allowed ? 1 : 0}:${tool.locked === true ? 1 : 0}`).join(\",\"),\n      spend === null ? \"-\" : `${spend.capUsd}:${spend.maxCapUsd ?? \"-\"}`,\n      savedHosts.join(\",\"),\n      `${fileAccess.scope}:${fileAccess.maxScope ?? \"-\"}:${fileAccess.locked === true ? 1 : 0}`,\n    ].join(\"|\")\n\n    const [overrides, setOverrides] = React.useState<AgentPermissionsDraft>({})\n    const [prev, setPrev] = React.useState({ token: baselineToken, saving: savingNow })\n    /**\n     * Generation of the save gate. It ticks when the baseline moves (the write\n     * landed) and when an in-flight write ends — so the one-shot lock expires by\n     * itself, including after a rejection, instead of leaving a dead button.\n     */\n    const [gen, setGen] = React.useState(0)\n    const [submittedGen, setSubmittedGen] = React.useState<number | null>(null)\n    /** Local text for the money field while it is being typed (see `capText`). */\n    const [capEdit, setCapEdit] = React.useState<{ value: number; text: string } | null>(null)\n    const [domainInput, setDomainInput] = React.useState(\"\")\n    const [domainError, setDomainError] = React.useState<string | null>(null)\n    const [flashHost, setFlashHost] = React.useState<string | null>(null)\n\n    const flashTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n    const stopFlash = React.useCallback(() => {\n      if (flashTimer.current) clearTimeout(flashTimer.current)\n      flashTimer.current = null\n    }, [])\n    // The timer is started from an event handler, never from an effect; this only\n    // guarantees it dies with the component.\n    React.useEffect(() => stopFlash, [stopFlash])\n\n    const controlled = value !== undefined\n    const draft: AgentPermissionsDraft = value ?? overrides\n\n    // Adjust state during render (no effect): there is never a frame in which the\n    // previous baseline's draft is applied to freshly loaded permissions.\n    if (prev.token !== baselineToken || prev.saving !== savingNow) {\n      const baselineMoved = prev.token !== baselineToken\n      setPrev({ token: baselineToken, saving: savingNow })\n      if (baselineMoved) {\n        if (!controlled) setOverrides({})\n        setCapEdit(null)\n        setDomainInput(\"\")\n        setDomainError(null)\n      }\n      if (baselineMoved || (prev.saving && !savingNow)) setGen(generation => generation + 1)\n    }\n\n    /**\n     * The one-shot lock. It lives in a REF as well as in state: several clicks\n     * dispatched inside a single task all read the same stale state, so a\n     * state-only guard would let the extra ones through and POST the change set\n     * twice.\n     */\n    const submittedRef = React.useRef(-1)\n    const submitted = submittedGen === gen\n\n    const metaOf = React.useCallback(\n      (scope: AgentFileAccessScope): AgentFileAccessMeta => ({\n        ...AGENT_FILE_ACCESS_META[scope],\n        ...fileAccessMeta?.[scope],\n      }),\n      [fileAccessMeta],\n    )\n    const effectOf = (scope: AgentFileAccessScope): string => {\n      const base = metaOf(scope).effect\n      return fileAccess.root && scope !== \"none\" ? `${base} Scoped to ${fileAccess.root}.` : base\n    }\n\n    /* ------------------------------------------------ effective (draft) values */\n\n    const allowedIn = (map: AgentPermissionsDraft, tool: AgentPermissionTool): boolean => {\n      if (tool.locked === true) return tool.allowed\n      const wanted: boolean | undefined = map.tools?.[tool.id]\n      return wanted ?? tool.allowed\n    }\n\n    const capIn = (map: AgentPermissionsDraft): number =>\n      spend === null ? 0 : clampSpendCap(map.capUsd ?? spend.capUsd, spend.maxCapUsd)\n\n    /** Canonical, de-duplicated, locked entries guaranteed present. */\n    const hostsIn = (map: AgentPermissionsDraft): string[] => {\n      const seen = new Set<string>()\n      const out: string[] = []\n      for (const raw of map.domains ?? savedHosts) {\n        const host = normalizeDomainHost(raw)\n        if (host === \"\" || seen.has(host)) continue\n        seen.add(host)\n        out.push(host)\n      }\n      // A pinned host can never be dropped, whatever the overlay says.\n      const pinned = domains\n        .filter(domain => domain.locked === true)\n        .map(domain => normalizeDomainHost(domain.host))\n        .filter(host => host !== \"\" && !seen.has(host))\n      return pinned.length === 0 ? out : [...pinned, ...out]\n    }\n\n    const scopeIn = (map: AgentPermissionsDraft): AgentFileAccessScope =>\n      fileAccess.locked === true\n        ? fileAccess.scope\n        : clampFileAccessScope(map.fileAccess ?? fileAccess.scope, fileAccess.maxScope)\n\n    const sameHosts = (a: string[], b: string[]): boolean => {\n      if (a.length !== b.length) return false\n      const set = new Set(b)\n      return a.every(host => set.has(host))\n    }\n\n    /**\n     * Drop everything that is not a real change. The result IS the patch body —\n     * and note that the clamps are applied here too, so a saved value that sits\n     * outside a policy limit is staged for correction instead of being silently\n     * displayed as something the server does not have.\n     */\n    const patchOf = (next: AgentPermissionsDraft): AgentPermissionsDraft => {\n      const out: AgentPermissionsDraft = {}\n      const toolPatch: Record<string, boolean> = {}\n      for (const tool of tools) {\n        if (tool.locked === true) continue\n        const allowed = allowedIn(next, tool)\n        if (allowed !== tool.allowed) toolPatch[tool.id] = allowed\n      }\n      if (Object.keys(toolPatch).length > 0) out.tools = toolPatch\n\n      if (spend !== null) {\n        const cap = capIn(next)\n        if (cap !== clampSpendCap(spend.capUsd)) out.capUsd = cap\n      }\n\n      const hosts = hostsIn(next)\n      if (!sameHosts(hosts, savedHosts)) out.domains = hosts\n\n      if (fileAccess.locked !== true) {\n        const scope = scopeIn(next)\n        if (scope !== fileAccess.scope) out.fileAccess = scope\n      }\n      return out\n    }\n\n    const changesOf = (next: AgentPermissionsDraft): AgentPermissionChange[] => {\n      const out: AgentPermissionChange[] = []\n\n      for (const tool of tools) {\n        if (tool.locked === true) continue\n        const allowed = allowedIn(next, tool)\n        if (allowed === tool.allowed) continue\n        out.push({\n          kind: \"tool\",\n          id: tool.id,\n          label: tool.label ?? tool.name,\n          from: tool.allowed ? \"allowed\" : \"blocked\",\n          to: allowed ? \"allowed\" : \"blocked\",\n          direction: allowed ? \"expands\" : \"restricts\",\n        })\n      }\n\n      if (spend !== null) {\n        const cap = capIn(next)\n        const saved = clampSpendCap(spend.capUsd)\n        if (cap !== saved) {\n          out.push({\n            kind: \"spend\",\n            id: \"spend\",\n            label: \"Spend cap\",\n            from: money(saved),\n            to: money(cap),\n            direction: cap > saved ? \"expands\" : \"restricts\",\n          })\n        }\n      }\n\n      const hosts = hostsIn(next)\n      const savedSet = new Set(savedHosts)\n      const draftSet = new Set(hosts)\n      for (const host of hosts) {\n        if (savedSet.has(host)) continue\n        out.push({\n          kind: \"domain\",\n          id: `domain:${host}`,\n          label: host,\n          from: \"blocked\",\n          to: \"allowed\",\n          direction: \"expands\",\n        })\n      }\n      for (const host of savedHosts) {\n        if (draftSet.has(host)) continue\n        out.push({\n          kind: \"domain\",\n          id: `domain:${host}`,\n          label: host,\n          from: \"allowed\",\n          to: \"blocked\",\n          direction: \"restricts\",\n        })\n      }\n\n      if (fileAccess.locked !== true) {\n        const scope = scopeIn(next)\n        if (scope !== fileAccess.scope) {\n          out.push({\n            kind: \"file-access\",\n            id: \"file-access\",\n            label: \"File access\",\n            from: metaOf(fileAccess.scope).label,\n            to: metaOf(scope).label,\n            direction:\n              fileAccessScopeIndex(scope) > fileAccessScopeIndex(fileAccess.scope) ? \"expands\" : \"restricts\",\n          })\n        }\n      }\n\n      return out\n    }\n\n    const changes = changesOf(draft)\n    const expanding = changes.filter(change => change.direction === \"expands\").length\n\n    const commit = (next: AgentPermissionsDraft) => {\n      if (disabled) return\n      const patch = patchOf(next)\n      if (!controlled) setOverrides(patch)\n      onValueChange?.(patch, changesOf(patch))\n    }\n\n    /**\n     * Point at one chip for a moment — the answer to \"where did it go?\" after an\n     * add, and to \"which one?\" after a rejected duplicate. It is a ring, not an\n     * animation, so it still reads under prefers-reduced-motion.\n     */\n    const flash = (host: string) => {\n      stopFlash()\n      setFlashHost(host)\n      flashTimer.current = setTimeout(() => setFlashHost(null), 1200)\n    }\n\n    /* --------------------------------------------------------------- actions */\n\n    const draftHosts = hostsIn(draft)\n    const lockedHosts = new Set(\n      domains.filter(domain => domain.locked === true).map(domain => normalizeDomainHost(domain.host)),\n    )\n\n    const toggleTool = (tool: AgentPermissionTool) => {\n      commit({ ...draft, tools: { ...(draft.tools ?? {}), [tool.id]: !allowedIn(draft, tool) } })\n    }\n\n    const denyAllTools = () => {\n      const next: Record<string, boolean> = { ...(draft.tools ?? {}) }\n      for (const tool of tools) {\n        // A panic switch is not a master key: it steps over locked rows entirely.\n        if (tool.locked !== true) next[tool.id] = false\n      }\n      commit({ ...draft, tools: next })\n    }\n\n    const setCap = (amount: number) => {\n      commit({ ...draft, capUsd: amount })\n    }\n\n    const addDomain = () => {\n      if (disabled) return\n      const host = normalizeDomainHost(domainInput)\n      const error = domainHostError(host) ?? validateDomain?.(host)\n      if (error !== undefined) {\n        setDomainError(error)\n        return\n      }\n      if (draftHosts.includes(host)) {\n        // Not an error you have to fix — the outcome you wanted is already true.\n        // Say so and point at the chip that proves it.\n        setDomainError(`${host} is already on the allowlist.`)\n        setDomainInput(\"\")\n        flash(host)\n        return\n      }\n      if (draftHosts.length >= maxDomains) {\n        setDomainError(`This agent can reach at most ${maxDomains} domains. Remove one first.`)\n        return\n      }\n      setDomainError(null)\n      setDomainInput(\"\")\n      flash(host)\n      commit({ ...draft, domains: [...draftHosts, host] })\n    }\n\n    const removeDomain = (host: string) => {\n      commit({ ...draft, domains: draftHosts.filter(entry => entry !== host) })\n    }\n\n    const restoreDomain = (host: string) => {\n      if (draftHosts.includes(host)) return\n      commit({ ...draft, domains: [...draftHosts, host] })\n    }\n\n    const discard = () => {\n      setCapEdit(null)\n      setDomainInput(\"\")\n      setDomainError(null)\n      commit({})\n      onDiscard?.()\n    }\n\n    const save = () => {\n      if (disabled || savingNow || changes.length === 0) return\n      if (submittedRef.current === gen) return\n      submittedRef.current = gen\n      setSubmittedGen(gen)\n      const payload = patchOf(draft)\n      onSave?.(payload, changesOf(payload))\n    }\n\n    const show = (section: AgentPermissionsSection) => sections.includes(section)\n    const rootClass = cn(\"w-full min-w-0 rounded-lg border bg-card text-sm text-foreground\", className)\n\n    /* ------------------------------------------------------------ envelopes */\n\n    if (status === \"loading\") {\n      return (\n        <section aria-busy=\"true\" aria-label={label} className={rootClass} ref={ref} {...props}>\n          <span className=\"sr-only\" role=\"status\">\n            Loading agent permissions\n          </span>\n          <div aria-hidden=\"true\" className=\"flex flex-col gap-4 p-4\">\n            <div className=\"h-3 w-40 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n            {[0, 1, 2].map(row => (\n              <div className=\"flex items-center gap-3\" key={row}>\n                <div className=\"size-4 shrink-0 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" />\n                <div className=\"h-3 flex-1 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                <div className=\"h-5 w-9 shrink-0 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" />\n              </div>\n            ))}\n            <div className=\"h-9 w-full animate-pulse rounded-md bg-muted motion-reduce:animate-none\" />\n          </div>\n        </section>\n      )\n    }\n\n    if (status === \"error\") {\n      return (\n        <section aria-label={label} className={rootClass} ref={ref} {...props}>\n          <div className=\"flex flex-col items-start gap-2 p-4\" role=\"alert\">\n            <p className=\"flex items-center gap-2 font-medium\">\n              <CircleAlert aria-hidden=\"true\" className=\"size-4 shrink-0 text-destructive\" />\n              Couldn&apos;t load these permissions\n            </p>\n            <p className=\"min-w-0 whitespace-pre-wrap wrap-anywhere text-muted-foreground\">{errorMessage}</p>\n            {/* No half-open editor: switches nobody can trust are worse than none,\n                so the panel says nothing loaded and offers the retry. */}\n            {onRetry && (\n              <button\n                className={cn(\n                  \"inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors hover:bg-muted motion-reduce:transition-none\",\n                  FOCUS,\n                )}\n                onClick={onRetry}\n                type=\"button\"\n              >\n                <RefreshCcw aria-hidden=\"true\" className=\"size-3.5\" />\n                Try again\n              </button>\n            )}\n          </div>\n        </section>\n      )\n    }\n\n    if (status === \"empty\" || (tools.length === 0 && domains.length === 0 && spend === null)) {\n      return (\n        <section aria-label={label} className={rootClass} ref={ref} {...props}>\n          {emptyState ?? (\n            <div className=\"flex flex-col items-start gap-1.5 p-4\">\n              <p className=\"flex items-center gap-2 font-medium\">\n                <ShieldOff aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n                Nothing to scope\n              </p>\n              <p className=\"text-muted-foreground\">\n                This agent answers from the conversation alone — it calls no tools, reaches no network and touches\n                no files, so there is nothing here to allow or deny.\n              </p>\n            </div>\n          )}\n        </section>\n      )\n    }\n\n    /* ---------------------------------------------------------------- ready */\n\n    const allowedCount = tools.filter(tool => allowedIn(draft, tool)).length\n    /** What \"Block every tool\" can actually reach — a locked grant is not its business. */\n    const revocableCount = tools.filter(tool => tool.locked !== true && allowedIn(draft, tool)).length\n    const draftCap = capIn(draft)\n    const draftScope = scopeIn(draft)\n    const savedScopeStaged = fileAccess.locked !== true && scopeIn({}) !== fileAccess.scope\n\n    /**\n     * The money field keeps its own TEXT while it is being edited, but only while\n     * that text still belongs to the value on screen. If the draft cap moves for\n     * any other reason (discard, a save landing, a controlled value) the override\n     * stops matching and the canonical rendering takes over by itself — no effect,\n     * no stale text.\n     */\n    const capOverride = capEdit !== null && capEdit.value === draftCap ? capEdit : null\n    const capText = capOverride === null ? amountToText(draftCap) : capOverride.text\n    const capParsed = capOverride === null ? draftCap : parseAmount(capOverride.text)\n    const capInvalid = capParsed === null\n    const capClamped =\n      spend !== null && capParsed !== null && spend.maxCapUsd !== undefined && capParsed > clampSpendCap(spend.maxCapUsd)\n    const overspent = spend !== null && spend.usedUsd >= draftCap\n    const capBelowUsed = spend !== null && draftCap < spend.usedUsd && draftCap !== clampSpendCap(spend.capUsd)\n    // A cap of zero can't be divided into, so the meter is read as \"all of it\" the\n    // moment anything has been spent — and never as a NaN width.\n    const spendPercent =\n      spend === null || draftCap <= 0\n        ? spend !== null && spend.usedUsd > 0\n          ? 100\n          : 0\n        : Math.min(100, Math.max(0, (spend.usedUsd / draftCap) * 100))\n\n    const handleCapChange = (text: string) => {\n      if (disabled) return\n      const parsed = parseAmount(text)\n      if (parsed === null) {\n        // Keep the characters, refuse the value: committing NaN or 0 here would\n        // turn a half-typed number into a real budget change.\n        setCapEdit({ value: draftCap, text })\n        return\n      }\n      const clamped = spend === null ? parsed : clampSpendCap(parsed, spend.maxCapUsd)\n      setCapEdit({ value: clamped, text })\n      setCap(clamped)\n    }\n\n    const summary =\n      changes.length === 0\n        ? \"No unsaved permission changes.\"\n        : `${changes.length} ${changes.length === 1 ? \"change\" : \"changes\"}, ${expanding} of them expand what this agent can do. Unsaved.`\n\n    const showHeading = heading !== null && heading !== undefined && heading !== false\n\n    return (\n      <section\n        aria-label={showHeading ? undefined : label}\n        aria-labelledby={showHeading ? headingId : undefined}\n        className={rootClass}\n        data-dirty={changes.length > 0 || undefined}\n        ref={ref}\n        {...props}\n      >\n        <style href=\"zyeon-agent-permissions\" precedence=\"medium\">\n          {KEYFRAMES}\n        </style>\n\n        {/* One persistent live region, mounted for the whole ready branch: a bar\n            that appears at the same instant as its own role=\"status\" is usually\n            not announced at all. */}\n        <p className=\"sr-only\" role=\"status\">\n          {summary}\n        </p>\n\n        {(showHeading || Boolean(description)) && (\n          <div className=\"flex min-w-0 flex-col gap-0.5 border-b p-4\">\n            {showHeading && (\n              <h3 className=\"min-w-0 wrap-anywhere font-medium\" id={headingId}>\n                {heading}\n              </h3>\n            )}\n            {description && <p className=\"min-w-0 wrap-anywhere text-xs text-muted-foreground\">{description}</p>}\n          </div>\n        )}\n\n        <div className=\"flex min-w-0 flex-col divide-y\">\n          {show(\"tools\") && tools.length > 0 && (\n            <div aria-labelledby={toolsId} className=\"flex min-w-0 flex-col gap-2 py-3\" role=\"group\">\n              <div className=\"px-4\">\n                <SectionHeading\n                  action={\n                    <button\n                      aria-disabled={disabled || revocableCount === 0 || undefined}\n                      className={cn(\n                        \"shrink-0 rounded-md border px-2.5 py-1 text-xs transition-colors motion-reduce:transition-none\",\n                        FOCUS,\n                        disabled || revocableCount === 0\n                          ? \"cursor-default text-muted-foreground opacity-60\"\n                          : \"cursor-pointer hover:bg-muted\",\n                      )}\n                      data-deny-all=\"\"\n                      onClick={() => {\n                        if (disabled || revocableCount === 0) return\n                        denyAllTools()\n                      }}\n                      type=\"button\"\n                    >\n                      Block every tool\n                    </button>\n                  }\n                  icon={<Wrench aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n                  id={toolsId}\n                  note={`${allowedCount} of ${tools.length} tools allowed. A blocked tool is never offered to the model.`}\n                  title=\"Tools\"\n                />\n              </div>\n              <div className=\"flex min-w-0 flex-col divide-y border-y\">\n                {tools.map(tool => (\n                  <ToolRow\n                    allowed={allowedIn(draft, tool)}\n                    disabled={disabled}\n                    key={tool.id}\n                    onToggle={() => toggleTool(tool)}\n                    tool={tool}\n                  />\n                ))}\n              </div>\n            </div>\n          )}\n\n          {show(\"spend\") && spend !== null && (\n            <div aria-labelledby={spendId} className=\"flex min-w-0 flex-col gap-2.5 p-4\" role=\"group\">\n              <SectionHeading\n                icon={<Coins aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n                id={spendId}\n                note={\n                  spend.updatedBy\n                    ? `Set by ${spend.updatedBy}${spend.updatedAt ? ` · ${spend.updatedAt}` : \"\"}. The agent stops when the cap is reached.`\n                    : \"The agent stops as soon as the cap is reached — it never asks for more.\"\n                }\n                title=\"Spend cap\"\n              />\n\n              <div className=\"flex min-w-0 flex-wrap items-end gap-x-4 gap-y-2\">\n                <div className=\"flex min-w-0 flex-col gap-1\">\n                  <label className=\"text-xs text-muted-foreground\" htmlFor={capId}>\n                    Cap {spend.periodLabel ?? \"per period\"}\n                  </label>\n                  <input\n                    aria-describedby={capNoteId}\n                    // readOnly, not the native `disabled`: a disabled input leaves the\n                    // tab order and can't be selected or copied, so a member without\n                    // the permission can no longer even read the budget they are under.\n                    aria-disabled={disabled || undefined}\n                    aria-invalid={capInvalid || undefined}\n                    className={cn(\n                      \"w-32 rounded-md border bg-background px-2.5 py-1.5 tabular-nums\",\n                      FOCUS,\n                      capInvalid && \"border-destructive\",\n                      disabled && \"cursor-not-allowed opacity-60\",\n                    )}\n                    data-cap-input=\"\"\n                    id={capId}\n                    inputMode=\"decimal\"\n                    readOnly={disabled}\n                    onBlur={() => setCapEdit(null)}\n                    onChange={event => handleCapChange(event.target.value)}\n                    // Text, not number: a number input hands you an empty string for\n                    // \"12abc\" and swallows the keystroke, so the field can't explain\n                    // itself. Parsing is ours, and so is the error.\n                    type=\"text\"\n                    value={capText}\n                  />\n                </div>\n                <p className=\"min-w-0 flex-1 wrap-anywhere text-xs text-muted-foreground tabular-nums\">\n                  <span className={cn(\"font-medium\", overspent ? \"text-destructive\" : \"text-foreground\")}>\n                    {money(spend.usedUsd)}\n                  </span>{\" \"}\n                  spent of {money(draftCap)} {spend.periodLabel ?? \"this period\"}\n                </p>\n              </div>\n\n              <div\n                aria-label=\"Budget used\"\n                aria-valuemax={100}\n                aria-valuemin={0}\n                aria-valuenow={Math.round(spendPercent)}\n                aria-valuetext={`${money(spend.usedUsd)} of ${money(draftCap)} used`}\n                className=\"h-1.5 w-full overflow-hidden rounded-full bg-muted\"\n                role=\"progressbar\"\n              >\n                <div\n                  className={cn(\n                    \"h-full rounded-full transition-[width] duration-200 motion-reduce:transition-none\",\n                    overspent ? \"bg-destructive\" : \"bg-primary\",\n                  )}\n                  style={{ width: `${spendPercent}%` }}\n                />\n              </div>\n\n              <p className=\"min-w-0 wrap-anywhere text-xs text-muted-foreground\" id={capNoteId}>\n                {capInvalid ? (\n                  <span className=\"text-destructive\">Enter an amount, for example 50 or 12.50.</span>\n                ) : capBelowUsed ? (\n                  <span className=\"text-destructive\">\n                    This cap is below the {money(spend.usedUsd)} already spent — the agent stops the moment you save.\n                  </span>\n                ) : capClamped ? (\n                  <span className=\"text-destructive\">\n                    Clamped to your plan ceiling of {money(clampSpendCap(spend.maxCapUsd ?? 0))}.\n                    {spend.maxReason ? ` ${spend.maxReason}` : \"\"}\n                  </span>\n                ) : overspent && draftCap > 0 ? (\n                  <span className=\"text-destructive\">The cap is reached — this agent can&apos;t spend again until the period resets.</span>\n                ) : draftCap === 0 ? (\n                  <span className=\"text-destructive\">A cap of zero blocks every paid call this agent makes.</span>\n                ) : spend.maxCapUsd !== undefined ? (\n                  `Your plan allows up to ${money(clampSpendCap(spend.maxCapUsd))}.${spend.maxReason ? ` ${spend.maxReason}` : \"\"}`\n                ) : (\n                  \"Usage is metered by your provider; the cap is enforced before each call.\"\n                )}\n              </p>\n            </div>\n          )}\n\n          {show(\"domains\") && (\n            <div aria-labelledby={domainsId} className=\"flex min-w-0 flex-col gap-2.5 p-4\" role=\"group\">\n              <SectionHeading\n                icon={<Globe aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n                id={domainsId}\n                note={\n                  draftHosts.length === 0\n                    ? \"Nothing is reachable. Add a host to let this agent fetch from it.\"\n                    : `${draftHosts.length} of ${maxDomains} hosts allowed. Everything else is refused before the request leaves.`\n                }\n                title=\"Network allowlist\"\n              />\n\n              {draftHosts.length === 0 && savedHosts.length === 0 ? (\n                <p className=\"text-xs text-muted-foreground\">\n                  This agent currently reaches nothing on the network.\n                </p>\n              ) : (\n                <ul className=\"flex min-w-0 flex-wrap items-center gap-1.5\">\n                  {/* Saved hosts keep their place even after you remove them: a\n                      removal is a STAGED change, and a chip that vanishes takes the\n                      undo with it. */}\n                  {savedHosts.map(host => {\n                    const entry: AgentDomain | undefined = domains.find(\n                      domain => normalizeDomainHost(domain.host) === host,\n                    )\n                    return (\n                      <DomainChip\n                        added={false}\n                        disabled={disabled}\n                        flash={flashHost === host}\n                        host={host}\n                        key={host}\n                        locked={lockedHosts.has(host)}\n                        note={entry?.note}\n                        onRemove={() => removeDomain(host)}\n                        onRestore={() => restoreDomain(host)}\n                        removed={!draftHosts.includes(host)}\n                      />\n                    )\n                  })}\n                  {draftHosts\n                    .filter(host => !savedHosts.includes(host))\n                    .map(host => (\n                      <DomainChip\n                        added\n                        disabled={disabled}\n                        flash={flashHost === host}\n                        host={host}\n                        key={host}\n                        locked={false}\n                        onRemove={() => removeDomain(host)}\n                        onRestore={() => restoreDomain(host)}\n                        removed={false}\n                      />\n                    ))}\n                </ul>\n              )}\n\n              <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n                <label className=\"sr-only\" htmlFor={domainInputId}>\n                  Add a domain to the allowlist\n                </label>\n                <input\n                  aria-describedby={domainError === null ? undefined : domainErrorId}\n                  aria-disabled={disabled || undefined}\n                  aria-invalid={domainError !== null || undefined}\n                  className={cn(\n                    \"min-w-0 flex-1 rounded-md border bg-background px-2.5 py-1.5 text-sm placeholder:text-muted-foreground\",\n                    FOCUS,\n                    domainError !== null && \"border-destructive\",\n                    disabled && \"cursor-not-allowed opacity-60\",\n                  )}\n                  data-domain-input=\"\"\n                  id={domainInputId}\n                  onChange={event => {\n                    if (disabled) return\n                    setDomainInput(event.target.value)\n                    if (domainError !== null) setDomainError(null)\n                  }}\n                  onKeyDown={event => {\n                    // Enter and comma both commit — people paste comma-separated\n                    // lists, and a form-less Enter would otherwise do nothing.\n                    if (event.key === \"Enter\" || event.key === \",\") {\n                      event.preventDefault()\n                      addDomain()\n                    } else if (event.key === \"Escape\" && domainInput !== \"\") {\n                      event.preventDefault()\n                      setDomainInput(\"\")\n                      setDomainError(null)\n                    }\n                  }}\n                  placeholder={domainPlaceholder}\n                  readOnly={disabled}\n                  type=\"text\"\n                  value={domainInput}\n                />\n                <button\n                  aria-disabled={disabled || domainInput.trim() === \"\" || undefined}\n                  className={cn(\n                    \"inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition-colors motion-reduce:transition-none\",\n                    FOCUS,\n                    disabled || domainInput.trim() === \"\"\n                      ? \"cursor-default text-muted-foreground opacity-60\"\n                      : \"cursor-pointer hover:bg-muted\",\n                  )}\n                  data-domain-add=\"\"\n                  onClick={() => {\n                    if (disabled || domainInput.trim() === \"\") return\n                    addDomain()\n                  }}\n                  type=\"button\"\n                >\n                  <Plus aria-hidden=\"true\" className=\"size-3.5\" />\n                  Add\n                </button>\n              </div>\n\n              {domainError !== null && (\n                <p className=\"min-w-0 wrap-anywhere text-xs text-destructive\" id={domainErrorId} role=\"alert\">\n                  {domainError}\n                </p>\n              )}\n            </div>\n          )}\n\n          {show(\"files\") && (\n            <div aria-labelledby={filesId} className=\"flex min-w-0 flex-col gap-2.5 p-4\" role=\"group\">\n              <SectionHeading\n                icon={<FolderOpen aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n                id={filesId}\n                note={\n                  fileAccess.root\n                    ? `Everything below applies inside ${fileAccess.root} only.`\n                    : \"How far into your files this agent may go.\"\n                }\n                title=\"File access\"\n              />\n\n              <ScopeChoice\n                describedBy={fileAccess.maxScope !== undefined || fileAccess.locked === true ? filesNoteId : undefined}\n                disabled={disabled || fileAccess.locked === true}\n                effectOf={effectOf}\n                labelledBy={filesId}\n                max={fileAccess.locked === true ? undefined : fileAccess.maxScope}\n                metaOf={metaOf}\n                onSelect={scope => commit({ ...draft, fileAccess: scope })}\n                value={draftScope}\n              />\n\n              {(fileAccess.locked === true || fileAccess.maxScope !== undefined) && (\n                <p className=\"flex min-w-0 items-start gap-1.5 text-xs text-muted-foreground\" id={filesNoteId}>\n                  <Lock aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n                  <span className=\"min-w-0 wrap-anywhere\">\n                    {fileAccess.locked === true\n                      ? (fileAccess.lockedReason ?? \"File access is fixed by your workspace policy.\")\n                      : [\n                          `Your policy caps this at “${metaOf(fileAccess.maxScope ?? \"none\").label}”.`,\n                          fileAccess.maxReason,\n                          savedScopeStaged\n                            ? \"The saved value was above it, so the reduction is already staged.\"\n                            : undefined,\n                        ]\n                          .filter(Boolean)\n                          .join(\" \")}\n                  </span>\n                </p>\n              )}\n            </div>\n          )}\n        </div>\n\n        {changes.length > 0 && (\n          <div\n            className={cn(\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t bg-muted/40 p-3\", RISE)}\n            data-save-bar=\"\"\n          >\n            <p className=\"min-w-0 flex-1 wrap-anywhere text-xs\">\n              <span className=\"font-medium\">\n                {changes.length} {changes.length === 1 ? \"change\" : \"changes\"} staged\n              </span>\n              {/* Widening an agent's authority is the move worth naming out loud. */}\n              {expanding > 0 && (\n                <span className=\"text-destructive\">\n                  {\" · \"}\n                  {expanding} {expanding === 1 ? \"expands\" : \"expand\"} what it can do\n                </span>\n              )}\n            </p>\n            <div className=\"flex shrink-0 flex-wrap items-center gap-2\">\n              <button\n                aria-disabled={disabled || savingNow || undefined}\n                className={cn(\n                  \"rounded-md border px-2.5 py-1 text-xs transition-colors motion-reduce:transition-none\",\n                  FOCUS,\n                  disabled || savingNow ? \"cursor-default opacity-60\" : \"cursor-pointer hover:bg-muted\",\n                )}\n                data-discard=\"\"\n                onClick={() => {\n                  if (disabled || savingNow) return\n                  discard()\n                }}\n                type=\"button\"\n              >\n                Discard\n              </button>\n              {onSave && (\n                <button\n                  aria-disabled={disabled || savingNow || submitted || undefined}\n                  className={cn(\n                    \"inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground transition-colors motion-reduce:transition-none\",\n                    FOCUS,\n                    disabled || savingNow || submitted\n                      ? \"cursor-default opacity-60\"\n                      : \"cursor-pointer hover:bg-primary/90\",\n                  )}\n                  data-save=\"\"\n                  onClick={save}\n                  type=\"button\"\n                >\n                  {(savingNow || submitted) && (\n                    <LoaderCircle aria-hidden=\"true\" className=\"size-3.5 animate-spin motion-reduce:animate-none\" />\n                  )}\n                  {savingNow || submitted ? \"Saving…\" : saveLabel}\n                </button>\n              )}\n            </div>\n          </div>\n        )}\n      </section>\n    )\n  },\n)\n\nAgentPermissions.displayName = \"AgentPermissions\"\n\nexport default AgentPermissions\n",
      "type": "registry:ui"
    },
    {
      "path": "src/registry/ui/agent-permissions.contract.ts",
      "content": "import { z } from \"zod\"\n\n/* -------------------------------------------------------------------------- *\n * Agent Permissions — the contract\n *\n * Four different KINDS of authority live in one panel, and each one is shaped by\n * what it actually is:\n *\n *  - tools      → a set of booleans (a capability is on or off),\n *  - spend      → a number with a ceiling and a meter running against it,\n *  - domains    → an unordered SET of hosts (add / remove, not edit-in-place),\n *  - fileAccess → an ORDERED scale (none < read < read-write).\n *\n * What unifies them is direction: every edit either EXPANDS what the agent may\n * do or RESTRICTS it, and the panel says which. That is only expressible because\n * the file-access scale is ordered and the rest have a natural \"more / less\".\n * -------------------------------------------------------------------------- */\n\n/**\n * How much damage one tool can do when the agent is wrong. Three bands, not a\n * score: a number would imply a precision nobody can defend, while \"high\" is a\n * claim a reviewer can argue with. Ordered low → high so the panel can warn when\n * an edit turns a high-risk capability ON.\n */\nexport const AGENT_PERMISSION_RISKS = [\"low\", \"medium\", \"high\"] as const\nexport const agentPermissionRiskSchema = z.enum(AGENT_PERMISSION_RISKS)\nexport type AgentPermissionRisk = z.infer<typeof agentPermissionRiskSchema>\n\n/**\n * File authority as an ORDERED scale, not two booleans. \"Read\" is not \"write\n * minus something\": it is the middle rung, and modelling it as a scale is what\n * lets a policy ceiling say \"this far and no further\" and lets the panel call a\n * move down a restriction.\n */\nexport const AGENT_FILE_ACCESS_SCOPES = [\"none\", \"read\", \"read-write\"] as const\nexport const agentFileAccessScopeSchema = z.enum(AGENT_FILE_ACCESS_SCOPES)\nexport type AgentFileAccessScope = z.infer<typeof agentFileAccessScopeSchema>\n\n/** Ordinal position on the scale, 0 (no files) … 2 (create / edit / delete). */\nexport function fileAccessScopeIndex(scope: AgentFileAccessScope): number {\n  return AGENT_FILE_ACCESS_SCOPES.indexOf(scope)\n}\n\n/**\n * Lower `scope` to `max`. Never raises — a ceiling is a one-way constraint, and\n * raising towards it would hand the agent authority nobody asked for. A saved\n * value ABOVE the ceiling therefore arrives already clamped, which the panel\n * shows as a change staged on load rather than as a silent downgrade.\n */\nexport function clampFileAccessScope(\n  scope: AgentFileAccessScope,\n  max?: AgentFileAccessScope,\n): AgentFileAccessScope {\n  if (max === undefined) return scope\n  return fileAccessScopeIndex(scope) > fileAccessScopeIndex(max) ? max : scope\n}\n\n/**\n * Money is a plain number of currency units, normalised to cents in ONE place: a\n * cap edited through a text field is one keystroke away from `12.005`, and two\n * floats that differ in the eighth decimal would show up as a permanent unsaved\n * change nobody can discard. Negatives collapse to 0 — \"minus five dollars of\n * budget\" is not a state worth rendering.\n */\nexport function clampSpendCap(amount: number, max?: number): number {\n  if (!Number.isFinite(amount) || amount <= 0) return 0\n  const cents = Math.round(amount * 100) / 100\n  if (max === undefined || !Number.isFinite(max)) return cents\n  return Math.min(cents, Math.max(0, Math.round(max * 100) / 100))\n}\n\n/**\n * Canonical form of a host, because equality of an allowlist is SET equality and\n * a set needs one spelling per member. Everything a human pastes collapses here:\n * `https://API.Example.com:443/v1?x=1` and `api.example.com` are the same entry,\n * so the panel can refuse the duplicate instead of storing both and letting the\n * server decide which one wins.\n */\nexport function normalizeDomainHost(input: string): string {\n  return input\n    .trim()\n    .toLowerCase()\n    // scheme, then userinfo, then path / query / hash, then port, then a root dot\n    .replace(/^[a-z][a-z0-9+.-]*:\\/\\//, \"\")\n    .replace(/^[^/@]*@/, \"\")\n    .replace(/[/?#].*$/, \"\")\n    .replace(/:\\d+$/, \"\")\n    .replace(/\\.+$/, \"\")\n}\n\nconst HOST_PATTERN = /^(\\*\\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\\.)+[a-z]{2,}$/\n\n/**\n * Why a host is not acceptable, in the user's words — `undefined` when it is.\n * A bare `*` is rejected on purpose: an allowlist whose only entry allows the\n * whole internet is not an allowlist, and typing one is nearly always a\n * misunderstanding of the field rather than an intent.\n */\nexport function domainHostError(host: string): string | undefined {\n  if (host === \"\") return \"Enter a domain, for example api.example.com\"\n  if (host === \"*\" || host === \"*.\") {\n    return \"Use a specific host — a bare * would allow every site on the internet.\"\n  }\n  if (host.length > 253) return \"A host name can't be longer than 253 characters.\"\n  if (host.split(\".\").some(part => part.length > 63)) {\n    return \"Each label in a host name must be 63 characters or fewer.\"\n  }\n  if (!HOST_PATTERN.test(host)) {\n    return \"That doesn't look like a host name. Try api.example.com or *.example.com.\"\n  }\n  return undefined\n}\n\n/**\n * One capability the agent may or may not call.\n *\n * `allowed` is the SAVED value — the baseline every draft is diffed against. The\n * panel never mutates it; the consumer flips it when the write lands, and that\n * flip is what clears the unsaved-changes bar.\n */\nexport const agentPermissionToolSchema = z.object({\n  /** Stable key. Also the key in the sparse patch handed to `onSave`. */\n  id: z.string(),\n  /** The identifier the model calls, e.g. `run_shell`. Rendered monospace. */\n  name: z.string(),\n  /** Human wording for the row heading. Falls back to `name`. */\n  label: z.string().optional(),\n  /** One line on what the tool can reach, in the user's words. */\n  description: z.string().optional(),\n  risk: agentPermissionRiskSchema,\n  /** The persisted grant. */\n  allowed: z.boolean(),\n  /** Not editable here at all — a platform or org decision. Bulk actions step over it. */\n  locked: z.boolean().optional(),\n  /** Why it is locked. A lock without a reason reads as a bug. */\n  lockedReason: z.string().optional(),\n  /** Usage provenance — the number that makes a revoke decidable (\"42 calls in 7 days\"). */\n  calls: z.number().optional(),\n  /** Extra sentence shown only while a HIGH-risk tool is being turned on. */\n  warning: z.string().optional(),\n})\nexport type AgentPermissionTool = z.infer<typeof agentPermissionToolSchema>\n\n/**\n * The budget. `usedUsd` is a METER, not a setting: it moves on its own while the\n * panel is open, and it is deliberately excluded from the draft baseline so a\n * tick of spending can never wipe out an edit in progress.\n */\nexport const agentSpendCapSchema = z.object({\n  /** The persisted ceiling for the period. `0` means the agent may spend nothing. */\n  capUsd: z.number(),\n  /** Spent so far in the current period. Read-only, supplied by your metering. */\n  usedUsd: z.number(),\n  /** ISO 4217 code, used for formatting only. Default \"USD\". */\n  currency: z.string().optional(),\n  /** Pre-formatted by you — \"this month\", \"per run\". The panel owns no clock. */\n  periodLabel: z.string().optional(),\n  /** Plan / org ceiling. A draft above it is clamped and the clamp is named. */\n  maxCapUsd: z.number().optional(),\n  /** Why the ceiling exists. */\n  maxReason: z.string().optional(),\n  /** Who last moved the cap — provenance matters when a budget grows. */\n  updatedBy: z.string().optional(),\n  /** Pre-formatted by you (\"Mar 12\"). */\n  updatedAt: z.string().optional(),\n})\nexport type AgentSpendCap = z.infer<typeof agentSpendCapSchema>\n\n/** One entry of the network allowlist. */\nexport const agentDomainSchema = z.object({\n  /** Host or wildcard host: `api.stripe.com`, `*.acme.dev`. Stored normalised. */\n  host: z.string(),\n  /** Pinned by policy — the chip has no remove control at all. */\n  locked: z.boolean().optional(),\n  /** Why this host is on the list. Rendered under the chip row on hover / focus. */\n  note: z.string().optional(),\n})\nexport type AgentDomain = z.infer<typeof agentDomainSchema>\n\n/** Where the agent may read and write, and how far it is allowed to go. */\nexport const agentFileAccessSchema = z.object({\n  /** The persisted rung. */\n  scope: agentFileAccessScopeSchema,\n  /** The directory every rung is scoped to, e.g. `/workspace`. Named in the effect lines. */\n  root: z.string().optional(),\n  /** Policy ceiling: rungs above it are unreachable by pointer and by keyboard. */\n  maxScope: agentFileAccessScopeSchema.optional(),\n  /** Why the ceiling exists. Rendered next to the unreachable rungs. */\n  maxReason: z.string().optional(),\n  /** Not editable at all. */\n  locked: z.boolean().optional(),\n  lockedReason: z.string().optional(),\n})\nexport type AgentFileAccess = z.infer<typeof agentFileAccessSchema>\n\n/**\n * The draft overlay — **sparse on purpose**. A key exists only while it differs\n * from the saved value, so the object IS the PATCH body and \"untouched\" never\n * means \"explicitly resent the current value\". Moving anything back onto its\n * saved value deletes the key again, and `{}` means \"nothing to save\".\n *\n * `tools` is a sparse map because a grant is per-tool. `domains` is the WHOLE\n * desired list because a set has no stable index to patch: sending\n * `[\"a\",\"b\"]` is unambiguous, sending `{ remove: [\"c\"] }` races with anyone else\n * editing the same list.\n */\nexport const agentPermissionsDraftSchema = z.object({\n  tools: z.record(z.string(), z.boolean()).optional(),\n  capUsd: z.number().optional(),\n  domains: z.array(z.string()).optional(),\n  fileAccess: agentFileAccessScopeSchema.optional(),\n})\nexport type AgentPermissionsDraft = z.infer<typeof agentPermissionsDraftSchema>\n\n/**\n * One staged change, as handed to `onValueChange` / `onSave`.\n *\n * `direction` is the whole reason this type exists: \"6 changes\" is noise, \"6\n * changes, 2 of which expand what the agent can do\" is a review.\n */\nexport const agentPermissionChangeSchema = z.object({\n  kind: z.enum([\"tool\", \"spend\", \"domain\", \"file-access\"]),\n  /** Tool id, `spend`, `domain:<host>` or `file-access`. */\n  id: z.string(),\n  label: z.string(),\n  /** Pre-formatted before / after (\"blocked\" → \"allowed\", \"$50.00\" → \"$200.00\"). */\n  from: z.string(),\n  to: z.string(),\n  direction: z.enum([\"expands\", \"restricts\"]),\n})\nexport type AgentPermissionChange = z.infer<typeof agentPermissionChangeSchema>\n\n/** Envelope state — whether there is a permission set to edit at all. */\nexport const agentPermissionsStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\nexport type AgentPermissionsStatus = z.infer<typeof agentPermissionsStatusSchema>\n\n/**\n * What a data layer / mock factory hands over; the demo spreads it into the\n * props. `spend` is nullable because plenty of deployments meter nothing — that\n * is a missing BLOCK, not a zero budget, and the two must not look alike.\n */\nexport const agentPermissionsSchema = z.object({\n  status: agentPermissionsStatusSchema,\n  tools: z.array(agentPermissionToolSchema),\n  spend: agentSpendCapSchema.nullable(),\n  domains: z.array(agentDomainSchema),\n  fileAccess: agentFileAccessSchema,\n})\nexport type AgentPermissionsData = z.infer<typeof agentPermissionsSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}