SLOPSHOPPER

explain-permission

Explains each Claude Code permission request in plain English (WHAT, WHY, RISK) in a side pane

paneguardcommandtoastmodeltimer
v0.2.0MITupdated 2026-10-18petershk/explain-permission
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · explain-permission
│ ┃ What is Claude asking? ✕ › fix the failing auth test and add an audit log call │ ┃ No permission requests yet. When Claude asks │ ┃ "is this ok?", the plain-English version ⏺ Read(src/auth.ts) │ ┃ shows up here. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /explain-permission │ ⎿ explain-permission: Permission explainer opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · What is Claude asking?
No permission requests yet. When Claude asks "is this ok?", the plain-English version shows up here.
README

explain-permission

A Claude Code mod. When Claude stops to ask "is this ok?" before running something, a pane opens beside the conversation and explains the request in plain English:

  • WHAT (cyan): what it will actually do on your computer or online
  • WHY (magenta): why it's needed for what you asked, based on the conversation
  • RISK (green / yellow / red): low, medium or high, and anything that gets sent, deleted, published or paid for

The dialog appears right away; the explanation fills in a few seconds later. Read the pane before you answer.

For example, before a gh repo archive loop, the pane reads something like:

What is Claude asking?

WHAT
Archives 52 of your GitHub repositories, one after another. Archived repos become read-only
but keep all their code and history.

WHY
You agreed to the cleanup list Claude proposed; these are the repos it marked for archiving.

RISK · MEDIUM
This changes your GitHub account. It can be undone repo by repo with gh repo unarchive.

Use it

claude --plugin-dir /path/to/explain-permission

Type /explain-permission to open the pane yourself. It docks on the right in fullscreen mode, and otherwise sits above the prompt. It opens by itself on terminals at least 144 columns wide.

Good to know

  • Needs a recent Claude Code with mods (function hooks), an early-access feature.
  • Each explanation is one extra model call on your own account. It asks Claude, with the conversation so far, and falls back to Haiku without the conversation if that fails.
  • It only explains. It never approves or denies anything for you.
  • While the question is open, the dialog has the keyboard, so you can't scroll the pane yourself. When the explanation doesn't fit, the pane scrolls through it by itself: WHY, then RISK, then back to the top, six seconds each. It stops once you answer, or as soon as you scroll it with the mouse wheel.

Made by Hermann Peterscheck · https://hermann.fyi

Install from GitHub

This folder is also a plugin marketplace. Once it's in a GitHub repo:

claude plugin marketplace add petershk/explain-permission
claude plugin install explain-permission@hermann-mods

or the same with /plugin inside Claude Code. Needs Claude Code 2.1.287 or later.

Source 2 files
hooks/register.tsx 194 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Explanation } from '../types'
5
6const PANE = 'explain-permission'
7const TITLE = 'What is Claude asking?'
8const current = atom({ plugin: 'explain-permission', key: 'current' } as const, null)
9
10// The request as one readable line: the command for Bash, the file for edits, else the arguments.
11export function describe(tool: string, input: unknown): string {
12  const args = (input ?? {}) as Record<string, unknown>
13  if (typeof args.command === 'string') return args.command
14  if (typeof args.file_path === 'string') return args.file_path
15  if (typeof args.url === 'string') return args.url
16  const json = JSON.stringify(args)
17  return json.length > 400 ? json.slice(0, 400) + '...' : json
18}
19
20export function promptFor(tool: string, request: string): string {
21  return [
22    'A permission dialog is asking the user whether to let you run this:',
23    `Tool: ${tool}`,
24    `Request: ${request}`,
25    '',
26    'Explain it to someone who is not a programmer, in plain English, with no jargon and no markdown.',
27    'Write three short lines, each starting with its label:',
28    'WHAT: what this will actually do on their computer or online.',
29    'WHY: why it is needed for what they asked for (use the conversation so far).',
30    'RISK: low, medium or high, and what could go wrong or what changes for good (anything sent, deleted, published or paid for).',
31    'Keep it under 90 words. Do not run anything.',
32  ].join('\n')
33}
34
35type Section = { label?: 'WHAT' | 'WHY' | 'RISK'; body: string }
36
37// Splits the reply into its WHAT / WHY / RISK parts; a line under no label stays plain text.
38export function sections(text: string): Section[] {
39  const out: Section[] = []
40  for (const raw of text.split('\n')) {
41    const line = raw.replace(/\*\*/g, '').trim()
42    if (!line) continue
43    const m = line.match(/^(WHAT|WHY|RISK)\s*:\s*(.*)$/i)
44    if (m) out.push({ label: m[1]!.toUpperCase() as Section['label'], body: m[2]! })
45    else if (out.length && out[out.length - 1]!.label) out[out.length - 1]!.body += ' ' + line
46    else out.push({ body: line })
47  }
48  return out
49}
50
51export function riskLevel(body: string): 'low' | 'medium' | 'high' | 'unknown' {
52  const m = body.match(/\b(low|medium|high)\b/i)
53  return m ? (m[1]!.toLowerCase() as 'low' | 'medium' | 'high') : 'unknown'
54}
55
56// WHAT is cyan, WHY is magenta, RISK is green / yellow / red by its level.
57function colorFor(label: Section['label'], body: string): string {
58  if (label === 'WHAT') return 'cyan'
59  if (label === 'WHY') return 'magenta'
60  return { low: 'green', medium: 'yellow', high: 'red', unknown: 'yellow' }[riskLevel(body)]
61}
62
63// The request line is cut to this many rows of the pane, so a long command doesn't push the explanation out of view.
64const REQUEST_ROWS = 3
65
66export function clip(text: string, columns: number, rows = REQUEST_ROWS): string {
67  const most = Math.max(20, columns * rows)
68  return text.length > most ? text.slice(0, most - 3) + '...' : text
69}
70
71// While the "is this ok?" dialog is up it holds the keyboard, so the pane's own scroll keys can't reach it. When the
72// explanation is taller than the pane, the pane steps through it by itself: WHY, then RISK, then back to the top, a few
73// seconds each. It stops when the question is answered, a new one comes, or the person scrolls the pane themselves.
74// (When everything fits, the steps have nowhere to go and nothing moves.)
75const STEP_MS = 6000
76const PASSES = 3
77let tour: { id: number; tool: string; cancel: () => void } | undefined
78
79function stopTour() {
80  tour?.cancel()
81  tour = undefined
82}
83
84export function tourSteps(parts: Section[]): string[] {
85  const keys = parts.filter(p => p.label && p.label !== 'WHAT').map(p => p.label!.toLowerCase())
86  return keys.length ? [...keys, 'top'] : []
87}
88
89function startTour($: EngineInterface, id: number, tool: string, steps: string[]) {
90  stopTour()
91  if (!steps.length) return
92  let at = 0
93  const timer = $.clock.every(STEP_MS, () => {
94    if (at >= steps.length * PASSES) return stopTour()
95    const step = steps[at++ % steps.length]!
96    void $.ui.scroll(step === 'top' ? { to: 'start', in: PANE } : { to: { key: step }, in: PANE, block: 'start' }).catch(() => {})
97  })
98  tour = { id, tool, cancel: () => timer.cancel() }
99}
100
101let nextId = 1
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    await $.command.register({
106      name: 'explain-permission',
107      description: 'Show the plain-English permission explainer pane',
108    })
109    return next(e)
110  })
111
112  on('command.run', { command: 'explain-permission' }, async $ => {
113    await $.ui.open({ id: PANE, title: TITLE })
114    return { text: 'Permission explainer opened.' }
115  })
116
117  // Fires just before the engine shows the "is this ok?" dialog to the person.
118  on('classic.PermissionRequest', async ($, e, next) => {
119    const tool = e.tool_name
120    if (tool === 'AskUserQuestion' || tool === 'ExitPlanMode') return next(e)
121
122    const entry: Explanation = {
123      id: nextId++,
124      tool,
125      request: describe(tool, e.tool_input),
126      status: 'thinking',
127      text: '',
128    }
129    stopTour()
130    await update($, current, () => entry)
131    void $.ui.scroll({ to: 'start', in: PANE }).catch(() => {})
132    const opened = await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
133    if (opened && !opened.isPlaced) $.ui.toast('Permission explainer: widen the terminal or type /explain-permission to see it')
134
135    // Explain in the background so the dialog is never held up.
136    void (async () => {
137      const prompt = promptFor(tool, entry.request)
138      let reply = await $.model.fork({ prompt })
139      if (!reply.isAnswered) reply = await $.model.complete({ model: 'haiku', prompt, maxTokens: 300 })
140      const done: Partial<Explanation> = reply.isAnswered
141        ? { status: 'ready', text: reply.text.trim() }
142        : { status: 'failed', text: `Could not get an explanation (${reply.reason}).` }
143      const now = await update($, current, now => (now && now.id === entry.id ? { ...now, ...done } : now))
144      if (now?.id === entry.id && now.status === 'ready') startTour($, entry.id, tool, tourSteps(sections(now.text)))
145    })().catch(err => {
146      void update($, current, now =>
147        now && now.id === entry.id ? { ...now, status: 'failed' as const, text: String(err) } : now,
148      )
149    })
150
151    return next(e)
152  })
153
154  // The call ends once the question is answered (and the tool has run, or was refused): the tour has done its job.
155  on('tool.call', async ($, e, next) => {
156    const result = await next(e)
157    if (tour?.tool === e.tool) stopTour()
158    return result
159  })
160
161  // The person scrolled the pane themselves (the wheel, or its keys once the question is gone): leave it where they put it.
162  on('ui.scroll', { component: 'Pane', requestId: PANE }, ($, e, next) => {
163    if (e.origin.kind === 'person') stopTour()
164    return next(e)
165  })
166
167  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
168    const { Box, Text } = $.ui.resolve(e)
169    const now = await read($, current)
170    if (!now) return <Text dimColor>No permission requests yet. When Claude asks "is this ok?", the plain-English version shows up here.</Text>
171
172    return (
173      <Box flexDirection="column">
174        <Text bold>Claude wants to use: {now.tool}</Text>
175        <Text dimColor>{clip(now.request, e.props.bodyColumns)}</Text>
176        <Text> </Text>
177        {now.status === 'thinking' && <Text color="yellow">Working out what this means...</Text>}
178        {now.status === 'failed' && <Text color="red">{now.text}</Text>}
179        {now.status === 'ready' &&
180          sections(now.text).map((part, i, all) =>
181            part.label ? (
182              <Box key={part.label.toLowerCase()} flexDirection="column" marginBottom={i < all.length - 1 ? 1 : 0}>
183                <Text bold color={colorFor(part.label, part.body)}>{part.label === 'RISK' && riskLevel(part.body) !== 'unknown' ? `RISK · ${riskLevel(part.body).toUpperCase()}` : part.label}</Text>
184                <Text>{part.label === 'RISK' ? part.body.replace(/^(low|medium|high)\b[\s,.:;-]*/i, '') : part.body}</Text>
185              </Box>
186            ) : (
187              <Text>{part.body}</Text>
188            ),
189          )}
190      </Box>
191    )
192  })
193}
194
types/index.d.ts 14 lines
1export type Explanation = {
2  id: number
3  tool: string
4  request: string
5  status: 'thinking' | 'ready' | 'failed'
6  text: string
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'explain-permission': { current: Explanation | null }
12  }
13}
14