> Premium standalone component (part of no design system, works in any React + Tailwind app) from AI Canvas. Index of every component and install setup: https://aicanvas.me/llms.txt

# Permission Request

A permission card where an agent asks for access and you allow it once, for this chat or always for a pattern, then revoke it from the receipt.

- Category: AI Agent UI
- Built with: Motion, Tailwind CSS
- License: Premium (proprietary, not in the public repo)
- Page: https://aicanvas.me/components/permission-scope

## Install

```bash
npx shadcn@latest add @aicanvas/permission-scope
```

Requires Premium. Set your AI Canvas token as AICANVAS_TOKEN and register the @aicanvas registry with it, as described in the Install section of https://aicanvas.me/llms.txt. Without a Premium token the command writes a placeholder file instead of the component.

AI agents can also get its install command from the read-only AI Canvas MCP server (`npx -y @aicanvas/mcp`) and run it. Setup: https://aicanvas.me/mcp

Peer dependencies:

```bash
npm install framer-motion
```

## Props

### AccessRequest

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Stable request identifier that must be unique within the requests list. |
| `kind` | `AccessKind` |  | Selects the request wording, icon and scope choices. |
| `target` | `string` |  | Resource or command the agent wants to access. |
| `summary?` | `string` |  | Short context shown above the reason; left out, the reason moves up with no summary. |
| `why` | `string` |  | Explanation shown for why the access is needed. |
| `pattern?` | `string` |  | Initial editable pattern for always access; left out, a pattern is derived from kind and target. |
| `coverage?` | `(pattern: string) => string` |  | Describes what an edited always-access pattern covers; left out, built-in coverage text is used. |
| `allowAlways?` | `boolean` |  | Allows the always scope when true for non-secret requests; left out, always is allowed for every kind except secret. |

### PermissionScope

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `requests?` | `AccessRequest[]` |  | Supplies access requests shown one at a time; left out, the component plays its demo, while passing it turns the demo off. |
| `onDecide?` | `(id: string, decision: AccessDecision) => void` |  | Reports an allow or deny decision while the receipt is still tracked locally; left out, no decisions are reported. |
| `onRevoke?` | `(id: string) => void` |  | Reports revocation of chat or always access while the receipt still changes locally; left out, no revocations are reported. |
| `onReason?` | `(id: string, text: string) => void` |  | Receives the explanation submitted after a denial while the receipt still changes locally; left out, no explanations are reported. |
| `className?` | `string` |  | Adds classes to the outermost root; left out, only the built-in classes are used. |
| `tuning?` | `Partial<PermissionScopeTuning>` |  | Overrides permission measurements, timing and motion values; left out, every value in PERMISSION_SCOPE_TUNING is used. |

## About

Permission Scope is the card an agent shows when it needs a capability: read a folder, use the network, run a command class, access a secret. The scope is the decision, a segmented control for once, this session or always with the Allow label following it. Details expand to the exact paths or hosts in mono with checkboxes, so unchecking narrows the grant and the callback carries only what stayed checked. Write and secret tiers show a chip and disable always by default, secrets are masked with a reveal, and the decision folds into a receipt with undo and a Revoke link.

Use cases: AI coding agents, Browser agents, Enterprise assistants.

## Source

The source is Premium and is not published. Preview it at https://aicanvas.me/components/permission-scope.

## Remix prompt

The public part of the remix prompt: 1. Setup and 2. Constants. The rest of the build spec (state, markup, how it works) is part of Premium.

```markdown
# PermissionScope
An agent access request card with scoped grants, editable patterns, receipts, revocation and denial reasons.

## 1. Setup
// npm install framer-motion
File: components-workspace/permission-scope/index.tsx  ·  'use client'  ·  export default function PermissionScope(props: PermissionScopeProps)
Tailwind CSS v4, with the dark: variant following your theme. Colors come from the component variables in THEME_VARS.
The @/app/_icons imports are the AI Canvas icon set. Without it, use any icon components that accept size, weight and SVG props, with a matching IconProps type.

import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
import type * as React from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { RotateCcw } from '@/app/_icons/icons/rotate-ccw'
import { Check } from '@/app/_icons/icons/check'
import { X } from '@/app/_icons/icons/x'
import { Pen2 } from '@/app/_icons/icons/pen-2'
import { Send } from '@/app/_icons/icons/send'
import { GlobeDuotone } from '@/app/_icons/icons/globe-duotone'
import { FolderOpenDuotone } from '@/app/_icons/icons/folder-open-duotone'
import { KeyDuotone } from '@/app/_icons/icons/key-duotone'
import { TerminalDuotone } from '@/app/_icons/icons/terminal-duotone'
import { LockDuotone } from '@/app/_icons/icons/lock-duotone'
import { TriangleAlertDuotone } from '@/app/_icons/icons/triangle-alert-duotone'
import { CircleMinusDuotone } from '@/app/_icons/icons/circle-minus-duotone'
import type { IconProps } from '@/app/_icons/IconBase'

## 2. Constants
export type PermissionScopeTuning = {
  hoverMs: number
  pressScale: number
  stateMs: number
  enterMs: number
  enterY: number
  nextDelayMs: number
  growMs: number
  bodyExitMs: number
  resizeMs: number
  lightMs: number
  revokedHoldMs: number
  replayDelayMs: number
  cardMaxW: number
  cardGap: number
  receiptGap: number
  springMs: number
}

export const PERMISSION_SCOPE_TUNING: PermissionScopeTuning = {
  hoverMs: 150,
  pressScale: 0.97,
  stateMs: 200,
  enterMs: 280,
  enterY: 6,
  nextDelayMs: 180,
  growMs: 280,
  bodyExitMs: 120,
  resizeMs: 280,
  lightMs: 600,
  revokedHoldMs: 3000,
  replayDelayMs: 400,
  cardMaxW: 480,
  cardGap: 16,
  receiptGap: 8,
  springMs: 350,
}

const EASE = [0.23, 1, 0.32, 1] as const

export type AccessKind = 'network' | 'files' | 'secret' | 'command'

export type AccessScope = 'once' | 'chat' | 'always'

export type AccessRequest = {
  id: string
  kind: AccessKind
  target: string
  summary?: string
  why: string
  pattern?: string
  coverage?: (pattern: string) => string
  allowAlways?: boolean
}

export type AccessDecision = { allow: boolean; scope?: AccessScope; pattern?: string }

type PermissionScopeProps = {
  requests?: AccessRequest[]
  onDecide?: (id: string, decision: AccessDecision) => void
  onRevoke?: (id: string) => void
  onReason?: (id: string, text: string) => void
  className?: string
  tuning?: Partial<PermissionScopeTuning>
}

type Entry = {
  requestKey: string
  decision: AccessDecision
  revoke: 'none' | 'pending' | 'done'
  reason: 'closed' | 'open' | 'sent'
}

function requestKey(r: AccessRequest) {
  return JSON.stringify([
    r.kind,
    r.target,
    r.summary ?? null,
    r.why,
    r.pattern ?? null,
    r.coverage?.toString() ?? null,
    r.allowAlways ?? null,
  ])
}

type KindCopy = {
  icon: React.ComponentType<IconProps>
  title: (target: string) => string
  everything: string
  pattern: (target: string) => string
}

const KIND: Record<AccessKind, KindCopy> = {
  network: {
    icon: GlobeDuotone,
    title: (x) => `Wants to reach ${x}`,
    everything: 'Covers every website',
    pattern: (x) => `${x}/*`,
  },
  files: {
    icon: FolderOpenDuotone,
    title: (x) => `Wants to open ${x}`,
    everything: 'Covers every file on this machine',
    pattern: (x) => `${x}/**`,
  },
  secret: {
    icon: KeyDuotone,
    title: (x) => `Wants to use ${x}`,
    everything: 'Covers every stored secret',
    pattern: (x) => x,
  },
  command: {
    icon: TerminalDuotone,
    title: (x) => `Wants to run ${x}`,
    everything: 'Covers every command',
    pattern: (x) => `${x} *`,
  },
}

const SCOPE_LABEL: Record<AccessScope, string> = {
  once: 'Only this time',
  chat: 'For this chat',
  always: 'Always for',
}

const SCOPE_TRAIL: Record<'once' | 'chat', string> = {
  once: 'Just this request',
  chat: 'Until this chat ends',
}

const ALLOW_LABEL: Record<AccessScope, string> = {
  once: 'Allow once',
  chat: 'Allow for this chat',
  always: 'Always allow',
}

const RECEIPT_TAIL: Record<AccessScope, string> = {
  once: 'once',
  chat: 'for this chat',
  always: 'always',
}

const BREADTH: Record<AccessScope, number> = { once: 1, chat: 2, always: 3 }

const titleFor = (r: AccessRequest) => KIND[r.kind].title(r.target)

// A secret is never granted for good, whatever the request says.
const allowsAlways = (r: AccessRequest) => r.kind !== 'secret' && (r.allowAlways ?? true)

function stripScheme(p: string) {
  return p.trim().replace(/^[a-z]+:\/\//i, '')
}

function globToRegExp(glob: string) {
  const body = glob
    .split('*')
    .map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
    .join('.*')
  return new RegExp(`^${body}$`)
}

function joinAnd(items: string[]) {
  if (items.length < 2) return items.join('')
  return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`
}

const NO_MATCH = 'Does not match this request'

// A pattern that cannot reach its own target would grant something else: never allow it.
function reachesTarget(kind: AccessKind, pattern: string, target: string) {
  const p = stripScheme(pattern)
  if (!p) return false
  if (kind === 'network') return globToRegExp(p.split('/')[0]).test(stripScheme(target).split(/[/?#]/)[0])
  const re = globToRegExp(p)
  return [target, `${target}/`, `${target} `].some((x) => re.test(x))
}

function coversEverything(kind: AccessKind, pattern: string) {
  const p = stripScheme(pattern)
  if (!p.includes('*')) return false
  if (kind === 'network') return /^[*.]+$/.test(p.split('/')[0])
  return /^[*/.~\s]+$/.test(p)
}

function defaultCoverage(kind: AccessKind, pattern: string) {
  const p = stripScheme(pattern)
  if (kind === 'network') {
    const host = p.split('/')[0]
    const base = host.replace(/^[*.]+/, '')
    if (host.includes('*') && base) return `Covers every site under ${base}`
  }
  return 'Every request that matches this pattern'
}

const FORECAST = {
  host: 'api.weather.dev',
  path: '/v2/forecast',
  endpoints: ['/v2/forecast', '/v2/alerts', '/v2/history'],
}

function forecastCoverage(pattern: string) {
  const p = stripScheme(pattern)
  const host = p.split('/')[0]
  if (host.includes('*')) return defaultCoverage('network', p)
  if (host !== FORECAST.host) return NO_MATCH
  const re = globToRegExp(p.includes('/') ? p : `${p}/*`)
  const hits = FORECAST.endpoints.filter((e) => re.test(`${FORECAST.host}${e}`))
  if (!hits.includes(FORECAST.path)) return NO_MATCH
  const others = hits.filter((e) => e !== FORECAST.path)
  return others.length ? `Also covers ${joinAnd(others)}` : `Only ${FORECAST.path}`
}

const DEMO_REQUESTS: AccessRequest[] = [
  {
    id: 'forecast',
    kind: 'network',
    target: 'api.weather.dev',
    why: 'To fetch the 7-day forecast for your trip summary.',
    pattern: 'api.weather.dev/*',
    coverage: forecastCoverage,
  },
  {
    id: 'maps-key',
    kind: 'secret',
    target: 'MAPS_API_KEY',
    why: 'To draw the route map in the summary.',
  },
]

const THEME_VARS = [
  '[--ps-page:#EEEEF3] dark:[--ps-page:#0E0E0F]',
  '[--ps-panel:#F4F4FA] dark:[--ps-panel:#1B1B1C]',
  '[--ps-well:#EEEEF3] dark:[--ps-well:#0E0E0F]',
  '[--ps-raised:#DFDFE3] dark:[--ps-raised:#2D2D2E]',
  '[--ps-raised-hover:#CACACD] dark:[--ps-raised-hover:#373738]',
  '[--ps-lift:#FFFFFF] dark:[--ps-lift:#373738]',
  '[--ps-border:#DFDFE3] dark:[--ps-border:#2D2D2E]',
  '[--ps-border-strong:#CACACD] dark:[--ps-border-strong:#373738]',
  '[--ps-hairline:#DFDFE3] dark:[--ps-hairline:#2D2D2E]',
  '[--ps-rim:#CACACD] dark:[--ps-rim:#F4F4FA2E]',
  '[--ps-text:#1B1B1C] dark:[--ps-text:#F4F4FA]',
  '[--ps-body:#373738] dark:[--ps-body:#CACACD]',
  '[--ps-muted:#575759] dark:[--ps-muted:#9B9B9E]',
  '[--ps-faint:#7B7B7D]',
  '[--ps-selection:#A0A0A233] dark:[--ps-selection:#5E5E6333]',
  '[--ps-success:#047857] dark:[--ps-success:#34D399]',
  '[--ps-ai:#0369A1] dark:[--ps-ai:#7DD3FC]',
  '[--ps-attention:#B45309] dark:[--ps-attention:#FFB574]',
  '[--ps-red:#C8262C] dark:[--ps-red:#E5484D]',
  '[--ps-new:#6D28D9] dark:[--ps-new:#B79CFF]',
  '[--ps-primary:#1B1B1C] dark:[--ps-primary:#F4F4FA]',
  '[--ps-primary-hover:#373738] dark:[--ps-primary-hover:#FFFFFF]',
  '[--ps-on-primary:#F4F4FA] dark:[--ps-on-primary:#0E0E0F]',
  '[--ps-focus:#1B1B1C99] dark:[--ps-focus:#F4F4FA99]',
  '[--ps-focus-gap:#F4F4FA] dark:[--ps-focus-gap:#1B1B1C]',
  '[--ps-dot:#1B1B1C] dark:[--ps-dot:#F4F4FA]',
  '[--ps-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--ps-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
  '[--ps-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--ps-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
].join(' ')

const FOCUS =
  'outline-none focus-visible:ring-2 focus-visible:ring-(color:--ps-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--ps-focus-gap)'

const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"

const HIT_SM = `${HIT} pointer-coarse:after:-inset-1.5`

const HIT_CHIP = `${HIT} pointer-coarse:after:-inset-2.5`

const BTN = `${HIT_SM} ${FOCUS} inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] leading-4 font-semibold transition-[background-color,color,border-color,box-shadow] duration-150`

const BTN_PRIMARY = `${BTN} bg-(--ps-primary) text-(color:--ps-on-primary) shadow-(--ps-elev-1)`

const BTN_SECONDARY = `${BTN} border border-(color:--ps-border-strong) text-(color:--ps-text) hover:bg-(--ps-raised)`

const BTN_GHOST = `${BTN} text-(color:--ps-muted) hover:bg-(--ps-raised) hover:text-(color:--ps-text)`

const BTN_DANGER = `${BTN} border border-(color:--ps-border-strong) text-(color:--ps-red) hover:bg-[color-mix(in_srgb,var(--ps-red)_10%,transparent)]`

const TITLE = 'min-w-0 flex-1 text-[15px] leading-6 font-semibold text-(color:--ps-text) [overflow-wrap:anywhere]'

const META = 'shrink-0 text-[12px] leading-4 tabular-nums text-(color:--ps-muted)'

const MONO = 'font-mono text-[12px] leading-[18px]'

type Tone = 'neutral' | 'ai' | 'attention' | 'success' | 'red'

const TONE_VAR: Record<Tone, string> = {
  neutral: 'var(--ps-dot)',
  ai: 'var(--ps-ai)',
  attention: 'var(--ps-attention)',
  success: 'var(--ps-success)',
  red: 'var(--ps-red)',
}

const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, ai: 14, attention: 14, success: 10, red: 12 }

const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'

function subscribeReduced(onChange: () => void) {
  if (typeof window === 'undefined' || !window.matchMedia) return () => {}
  const mq = window.matchMedia(REDUCED_QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

function getReduced() {
  return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}

function getReducedServer() {
  return false
}

/**
 * @typedef {object} AccessRequest
 * @property {string} id Stable request identifier that must be unique within the requests list.
 * @property {AccessKind} kind Selects the request wording, icon and scope choices.
 * @property {string} target Resource or command the agent wants to access.
 * @property {string} [summary] Short context shown above the reason; left out, the reason moves up with no summary.
 * @property {string} why Explanation shown for why the access is needed.
 * @property {string} [pattern] Initial editable pattern for always access; left out, a pattern is derived from kind and target.
 * @property {(pattern: string) => string} [coverage] Describes what an edited always-access pattern covers; left out, built-in coverage text is used.
 * @property {boolean} [allowAlways] Allows the always scope when true for non-secret requests; left out, always is allowed for every kind except secret.
 */

/**
 * @typedef {object} PermissionScopeProps
 * @property {AccessRequest[]} [requests] Supplies access requests shown one at a time; left out, the component plays its demo, while passing it turns the demo off.
 * @property {(id: string, decision: AccessDecision) => void} [onDecide] Reports an allow or deny decision while the receipt is still tracked locally; left out, no decisions are reported.
 * @property {(id: string) => void} [onRevoke] Reports revocation of chat or always access while the receipt still changes locally; left out, no revocations are reported.
 * @property {(id: string, text: string) => void} [onReason] Receives the explanation submitted after a denial while the receipt still changes locally; left out, no explanations are reported.
 * @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
 * @property {Partial<PermissionScopeTuning>} [tuning] Overrides permission measurements, timing and motion values; left out, every value in PERMISSION_SCOPE_TUNING is used.
 */

type ItemProps = {
  req: AccessRequest
  index: number
  total: number
  entry: Entry | undefined
  last: boolean
  focusOnArrive: boolean
  t: PermissionScopeTuning
  reduce: boolean
  onDecide: (d: AccessDecision) => void
  onRevoke: () => void
  onRevokeSettled: () => void
  onReasonOpen: () => void
  onReasonClose: () => void
  onReasonSend: (text: string) => void
}

function receiptTone(entry: Entry): Tone {
  if (!entry.decision.allow) return 'red'
  if (entry.revoke === 'none') return 'success'
  return entry.revoke === 'pending' ? 'red' : 'neutral'
}

function receiptObject(req: AccessRequest, entry: Entry) {
  const d = entry.decision
  return d.allow && d.scope === 'always' && d.pattern ? d.pattern : req.target
}

function receiptVerb(entry: Entry) {
  if (!entry.decision.allow) return 'Denied'
  return entry.revoke === 'done' ? 'Revoked access to' : 'Allowed'
}

function receiptTail(entry: Entry) {
  const d = entry.decision
  if (d.allow) return entry.revoke === 'done' ? '' : RECEIPT_TAIL[d.scope ?? 'once']
  return entry.reason === 'sent' ? '· reason sent' : ''
}

function receiptLabel(req: AccessRequest, entry: Entry) {
  return [receiptVerb(entry), receiptObject(req, entry), receiptTail(entry)].filter(Boolean).join(' ')
}

type CardKeys = (e: React.KeyboardEvent<HTMLDivElement>) => void
```

## Related

- [Agent Status](https://aicanvas.me/components/agent-status.md): A status pill that says what the agent is doing, whose move it is, time and cost, and opens to the step history and token bill.
- [Code Block](https://aicanvas.me/components/code-panel.md): Code an agent just wrote streams in line by line, then numbered markers tie its notes to the exact lines, with Copy and Apply.
- [Agent Plan](https://aicanvas.me/components/plan-runner.md): An agent's step plan you can edit and start, then watch run one step at a time with live activity and a segmented progress bar.
- [Diff Review](https://aicanvas.me/components/change-review.md): Review an agent's proposed code edits change by change: read the reason, keep or discard each one, then apply only what you kept.
- [Agent Composer](https://aicanvas.me/components/agent-composer.md): The message input an agent sits behind: a textarea that grows to six rows, inline slash commands, @file mentions as removable chips, a drag-and-drop attachment row, a model picker with each provider's logo, and a round send button whose glyph becomes a pulsing stop square while the agent works.
