AI

Agent Permissions

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowDown,
  ArrowUp,
  CircleAlert,
  Coins,
  FolderOpen,
  Globe,
  LoaderCircle,
  Lock,
  Plus,
  RefreshCcw,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/agent-permissions.json

Prompt

Build a React + TypeScript + Tailwind "AgentPermissions" component with zod and
lucide-react. Four unlike controls in one panel, one shared staging model.

Contract
- A zod contract file is the single source of truth and exports four data shapes
  plus the draft:
  - Tool: { id, name, label?, description?, risk: "low"|"medium"|"high",
    allowed: boolean, locked?, lockedReason?, calls?, warning? }
  - SpendCap: { capUsd, usedUsd, currency?, periodLabel?, maxCapUsd?, maxReason?,
    updatedBy?, updatedAt? }
  - Domain: { host, locked?, note? }
  - FileAccess: { scope: "none"|"read"|"read-write", root?, maxScope?,
    maxReason?, locked?, lockedReason? }
  - Draft, the sparse overlay: { tools?: Record<string, boolean>,
    capUsd?: number, domains?: string[], fileAccess?: scope }
- The envelope is { status: "loading"|"empty"|"error"|"ready", tools, spend:
  SpendCap | null, domains, fileAccess }. `spend: null` means the deployment
  meters nothing — a missing BLOCK, not a cap of zero, and the two must never
  look alike.
- Each saved value is the BASELINE. The component never mutates it; the consumer
  flips it when the write lands, and that flip is what clears the unsaved bar.
- The file-access scale is ORDERED (none, then read, then read-write) and so are
  the risk bands. Ordering is what makes "this change expands authority"
  computable instead of editorial.
- Export helpers next to the schemas: fileAccessScopeIndex, clampFileAccessScope
  (lowers only, never raises), clampSpendCap (rounds to cents, floors at 0,
  clamps to a max), normalizeDomainHost, domainHostError.
- Props: status; tools; spend; domains; fileAccess; value? (controlled draft);
  onValueChange?(patch, changes); onSave?(patch, changes); onDiscard?; saving?;
  disabled?; sections? (default tools, spend, domains, files); maxDomains? (20);
  validateDomain?(host); formatMoney?(amount, currency); fileAccessMeta?;
  heading?; description?; domainPlaceholder?; emptyState?; errorMessage?;
  onRetry?; label?; saveLabel?; className plus the rest of the element props,
  ref forwarded to the <section>.

Behavior
- THE DRAFT IS A SPARSE OVERLAY on the saved data. A key exists only while it
  differs, so "changed" is derived rather than tracked and the object handed to
  onSave IS the patch body. Moving anything back onto its saved value deletes the
  key again; an empty object means nothing to save. `tools` is a sparse map
  because a grant is per-tool; `domains` is the WHOLE desired list, because a set
  has no stable index to patch and a remove-list races with a concurrent editor.
- EVERY CHANGE HAS A DIRECTION. Turning a tool on, raising the cap, adding a host
  and moving the file rung up all "expand"; the opposites "restrict". The save
  bar reads "6 changes staged · 2 expand what it can do", each row carries an
  arrow whose shape (not only colour) shows the direction, and the sr-only status
  line says the same thing in words.
- POLICY LIMITS ARE CONSTRAINTS ON THE CONTROL, not validation afterwards. Rungs
  above maxScope are struck through, unclickable AND skipped by the arrow keys; a
  cap above maxCapUsd is clamped as you type with the ceiling named; a locked
  tool, host or scope has no control at all, just a reason. If a SAVED value sits
  outside its limit (a ceiling added later), the panel arrives with the reduction
  already staged instead of displaying a value the server does not have — the
  same clamps run inside the patch builder, so that staged fix is really sent.
- THE MONEY FIELD IS TEXT, NOT type=number: a number input reports an empty
  string for "12abc" and swallows the keystroke, so it can never explain itself.
  Parse it yourself: strip currency symbols, commas and spaces, accept digits
  with at most one dot, round to cents, refuse negatives. Invalid text is KEPT on
  screen with aria-invalid and a message while the committed value stays
  untouched — a half-typed number must not become a real budget. On blur the
  field snaps back to the canonical rendering of the committed value.
- The field keeps its own text in state, but that text is only used while it
  still belongs to the value on screen: store { value, text } and compare value
  with the current draft cap. If the cap moves for any other reason — Discard, a
  save landing, a controlled value — the override stops matching and the
  canonical rendering takes over by itself. No effect, no stale text.
- HOSTS ARE A SET. Normalise on entry (lowercase, strip scheme, userinfo, path,
  query, port and a trailing dot) so `https://API.Example.com/v1` and
  `api.example.com` are one entry; validate the syntax, allow a leading `*.`, and
  reject a bare `*` explicitly — an allowlist whose only member is the whole
  internet is not an allowlist. A duplicate is not an error to fix: say it is
  already allowed and flash a ring on the chip that proves it. Enter AND comma
  commit (people paste comma-separated lists), Escape clears the field.
- REMOVING A SAVED HOST IS STAGED, NOT DESTRUCTIVE. The chip stays in place,
  struck through, with an undo — a chip that vanishes takes its undo with it.
  Removing a host you just added simply deletes it. Re-adding a removed host
  makes the set equal the saved one again, so the change leaves the bar by
  itself.
- SAVING IS ONE-SHOT. The lock lives in a ref that is read and written
  synchronously inside the click handler: several clicks dispatched in one task
  all read the same stale state, so a state-only guard would POST the change set
  twice. The lock is keyed to a generation that ticks when the baseline moves and
  when an in-flight save ends, so it expires by itself — including after a
  rejection — instead of leaving a dead button.
- The baseline identity deliberately EXCLUDES usedUsd. The meter ticks on its own
  while the panel is open, and a tick must never wipe out an edit in progress.
  Limits and locks ARE included: a policy tightened server-side is a new baseline
  and the draft has to be re-derived against it (adjust state during render, no
  effect, so there is never a frame where the old draft is applied to new data).
- Disabled is READ-ONLY, not invisible: aria-disabled everywhere, the native
  attribute nowhere, and the text inputs are readOnly rather than disabled so a
  member without the permission can still read, select and copy the budget they
  are under. Everything stays in the tab order.
- The four envelope states are first-class branches: loading is a skeleton with
  aria-busy and an sr-only status; error is role="alert" with an optional retry
  and NO half-open editor; empty says the agent has nothing to scope, which is
  not the same as an editor with nothing in it; ready is the panel.
- Timer discipline: the only timer is the chip flash. It is started from an event
  handler, cleared before it is restarted, and cleared on unmount.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary + text-primary-foreground for the switch track and Save, bg-primary
  /5 for a selected rung and a newly added chip, text-destructive with
  bg-destructive/10 for high risk, an exhausted budget and every "expands"
  marker. No hard-coded colours anywhere, so the panel is dark-mode-correct for
  free and re-themes with the host project's tokens.
- Semantics per control, never one generic widget: role="switch" for a grant,
  role="radiogroup" with roving tabindex, arrow keys, Home/End and
  selection-follows-focus for the ordered scale, role="progressbar" with
  aria-valuetext for the meter, a real label plus aria-invalid and
  aria-describedby for both inputs.
- One persistent sr-only role="status", mounted for the whole ready branch,
  states the summary; a live region that appears at the same instant as its own
  content is usually not announced at all.
- Long tool identifiers and long hosts use `wrap-anywhere` plus `min-w-0`, not
  `break-words`: only overflow-wrap:anywhere lowers the element's min-content
  width, which is what actually stops an 80-character MCP tool name from making
  the panel wider than its container.
- Every transition is motion-reduce-guarded and the skeleton pulse stops under
  prefers-reduced-motion; the chip highlight is a ring rather than an animation,
  so it survives there too. cn() merges the consumer's className into the root,
  which also spreads the remaining element props and forwards its ref.

Customization levers
- Blocks: `sections` picks which of tools / spend / domains / files render and in
  what order; `spend: null` drops the budget block from the data side. A surface
  with no network layer simply ships two sections.
- Policy: `maxDomains` caps the allowlist, `validateDomain` adds your own host
  rule (internal suffixes only, no wildcards, a blocklist) on top of the syntax
  check, and `locked` + `lockedReason` on any tool, host or file scope removes
  the control and explains itself.
- Wording: `fileAccessMeta` re-words any rung (label and effect sentence),
  `formatMoney` swaps currency rendering or shows credits instead of money, and
  `heading` / `description` / `label` / `saveLabel` / `domainPlaceholder` own the
  chrome. `emptyState`, `errorMessage` and `onRetry` own the envelopes.
- Control: pass `value` + `onValueChange` to hoist the draft (a wizard, a diff
  preview, a confirm dialog that lists the expansions before writing), or omit
  them and let the panel keep it. `saving` drives the in-flight state; falling
  back to false re-arms the gate after a rejection.
- Copy per row: `description` explains a tool in the user's words, `warning`
  replaces the sentence shown while a high-risk tool is being switched ON,
  `calls` supplies the usage figure that makes a revoke decidable, and `note` on
  a host says why it is on the list.

Concepts

  • Sparse draft overlay — the panel stores only what differs from the saved permissions, so "changed" is derived instead of tracked and the object it hands to onSave is already the patch body. Put a value back where it started and the key disappears again; an empty overlay means there is nothing to save, which is also why the unsaved bar can never linger over a no-op.
  • Direction of change — every edit either expands what the agent may do or restricts it, and only one of those halves is safe to skim past. Because the file-access rungs and the risk bands are ordered, the panel computes the direction rather than guessing it, and the bar leads with the number that matters: how many of these changes hand the agent more authority.
  • Policy ceiling as a constraint, not a complaint — an unreachable rung is struck through, unclickable and skipped by the arrow keys; a cap above the plan limit is clamped while you type. A value that is already outside its limit when the data arrives is shown as a staged correction, never as a number the server does not have — and the same clamps run inside the patch builder, so the correction is really sent.
  • Staged removal with undo — taking a host off the allowlist leaves the chip in place, struck through, with a one-click restore. A chip that disappears the moment you click the X takes its undo with it and turns a two-second mistake into a re-typing exercise; here the removal only becomes real when the write does.
  • Text field, owned parsing — the money input is text, because a number input reports an empty string for half-typed junk and swallows the keystroke. The panel keeps the characters on screen with aria-invalid while refusing to commit the value, so a stray letter can never become a real budget, and the local text steps aside by itself as soon as the committed value moves for another reason.
  • One-shot save gate — the guard is a ref read and written synchronously in the click handler, because several clicks dispatched in one task all observe the same stale state. The lock is keyed to a generation that ticks when the baseline moves or an in-flight write ends, so it expires on its own instead of leaving a button that can never be pressed again.

On This Page