# 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>) => voidState
- every hook, handler, effect and disposal
- the animation loop, frame by frame
Tree
- the JSX, every className and inline style
Why · Remix · Check
- the mechanism, the tuning points, the checks
Unlock Permission Request
The full source and the remix prompt ship with Premium.