SLOPSHOPPER

fuse

Предохранитель: перед опасной командой или отправкой наружу спрашивает, что делать, и объясняет последствия

newbandrowsguardpromptaudio
★ 15v0.3.1MITupdated 2026-10-09tsergeytovarov/ai-settings/plugins/fuse
A shopper browsing a rack in a slop shop
README

fuse — предохранитель

Плагин Claude Code. Перед опасной командой или отправкой наружу останавливает вызов и спрашивает, что делать.

Когда вопрос открывается, звучит короткая сирена sounds/alarm.wav. Звук генерирует sounds/scripts/make_sounds.py из соседнего плагина sounds. Плагин sounds такой вопрос пропускает, чтобы два звука не наложились.

Что видно, пока висит вопрос:

  • Красная карточка над вводом «⛔ Опасно»:
  • что произойдёт;
  • зачем модель это делает (её собственное описание вызова);
  • что затронет: для рекурсивного удаления каждый путь с числом файлов и размером, посчитанными на диске;
  • сама команда.
  • Вопрос «Команда удалит без корзины: build. Разрешить?».
  • Подписи у вариантов. У «Выполнить» — «Зачем: …» и превью с командой и списком затронутого.

Варианты ответа:

  • Выполнить — один раз.
  • Разрешать до конца сессии — это правило больше не спрашивает до конца сессии.
  • Запретить — вызов не выполняется, модель получает объяснение и указание не повторять без просьбы. Закрытый диалог тоже считается запретом.

Вопрос задаётся обычным диалогом выбора ($.ui.ask), поэтому работает и в режиме bypass. Подписи и превью мод дописывает в этот же диалог через ui.render на AskUserQuestion. Красить сам диалог движок не даёт, поэтому красная карточка живёт в полосе над вводом.

Как читается команда

Правила Bash смотрят только на начало каждой команды в цепочке: после ;, &&, ||, |, переменных окружения, sudo, xargs, env и подобных. Тела heredoc выкидываются, а кавычки не режут строку на команды. Поэтому опасная команда, упомянутая в тексте, сообщении коммита или скрипте внутри heredoc, не срабатывает. Первая версия ловила любое упоминание и останавливала даже правку собственного README.

Цена этого: команды внутри bash -c "…" и $(…) не ловятся.

Что ловится

ПравилоПримерЧто скажет
rm-recursiverm -rf ~/work/xудалит без корзины (кроме /tmp и scratchpad)
git-force-pushgit push --forceперезапишет историю ветки на сервере
git-reset-hardgit reset --hardсотрёт незакоммиченные изменения
git-cleangit clean -fdудалит неотслеживаемые файлы
git-discardgit checkout -- ., git restore fоткатит правки в файлах
git-branch-deletegit branch -D xудалит невлитую ветку
pipe-to-shell`curl … \bash`скачает и сразу выполнит скрипт
sudosudo …команда с правами администратора
cloud-deletekubectl delete, yc … deleteудалит ресурс в облаке
sql-destructive, mcp-sql-destructiveDROP, TRUNCATE, DELETE без WHEREудалит данные в базе
mcp-delete*_delete, *_trash, *_bulk_* в MCPудалит или массово изменит
artifact-deleteудаление артефактассылка перестанет работать
mattermost-postпост в Mattermostотправит сообщение
mail-sendsend_message, forward, replyотправит письмо
drive-sharedrive_shareоткроет доступ к файлу

Автозадачи

В сессии, которая началась с <scheduled-task, отвечать некому, и вопрос повесил бы задачу. Поэтому там разрушительное запрещается сразу, а посты и письма проходят: ради них автозадачи и запускаются.

Установка

claude plugin marketplace add ~/work/ai-settings/plugins
claude plugin install fuse@popovs-plugins

Разработка

  • hooks/rules.ts — правила: что ловится и как объясняется. Новое правило — одна запись в RULES.
  • hooks/register.tsx — хуки: перехват вызова, вопрос, подписи в диалоге, красная карточка, подсчёт удаляемого через $.fs.
  • hooks/targets.ts — как описать путь: файл, папка, сколько файлов и мегабайт.
  • Аргументы вызова лежат в самом событии tool.call рядом с tool (e.command), а не в e.input.
  • Проверка: claude plugin validate . и claude plugin test .
Source 4 files
hooks/register.tsx 231 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { AllowedRules, PendingDanger, TargetInfo } from '../types'
5import { findDanger } from './rules'
6import type { Danger } from './rules'
7import { targetLine } from './targets'
8
9const allowed = atom({ plugin: 'fuse', key: 'allowed' } as const, [] as AllowedRules)
10const isUnattended = atom({ plugin: 'fuse', key: 'isUnattended' } as const, false)
11// The question waiting for an answer, for the red card above the prompt.
12const pending = atom({ plugin: 'fuse', key: 'pending' } as const, null as PendingDanger | null)
13
14export const RUN = 'Выполнить'
15export const RUN_ALWAYS = 'Разрешать до конца сессии'
16export const STOP = 'Запретить'
17export const HEADER = '⛔ Опасно'
18export const ALARM = 'sounds/alarm.wav'
19
20// Folders a walk counts before it stops and says «больше».
21const WALK_LIMIT = 3000
22
23// Module state: the home folder, for `~` in a command (the engine does not expand it).
24let home: string | undefined
25
26// What an rm would take with it: per path, whether it is there, and for a
27// folder how many files and bytes lie under it (counted up to a limit).
28function resolve(path: string, home: string | undefined): string {
29  if (home !== undefined && (path === '~' || path.startsWith('~/'))) return home + path.slice(1)
30  return path
31}
32
33async function walk(
34  $: EngineInterface,
35  dir: string,
36  budget: { left: number },
37): Promise<{ files: number; bytes: number; isPartial: boolean }> {
38  let files = 0
39  let bytes = 0
40  let isPartial = false
41  const queue = [dir]
42  while (queue.length > 0) {
43    if (budget.left <= 0) {
44      isPartial = true
45      break
46    }
47    budget.left -= 1
48    const current = queue.shift()!
49    let entries
50    try {
51      entries = await $.fs.list(current)
52    } catch {
53      isPartial = true
54      continue
55    }
56    for (const entry of entries) {
57      if (entry.kind === 'dir' && !entry.isLink) queue.push(`${current}/${entry.name}`)
58      else {
59        files += 1
60        bytes += entry.size
61      }
62    }
63  }
64  return { files, bytes, isPartial }
65}
66
67export async function describeTargets(
68  $: EngineInterface,
69  targets: readonly string[],
70  home: string | undefined,
71  folderLimit: number,
72): Promise<TargetInfo[]> {
73  const budget = { left: folderLimit }
74  const described: TargetInfo[] = []
75  for (const path of targets) {
76    // A glob or a variable is the shell's to expand: say so rather than guess.
77    if (/[*?$`{[]/.test(path)) {
78      described.push({ path, kind: 'pattern' })
79      continue
80    }
81    try {
82      const stat = await $.fs.stat(resolve(path, home))
83      if (stat.kind === 'dir') {
84        const { files, bytes, isPartial } = await walk($, resolve(path, home), budget)
85        described.push({ path, kind: 'dir', files, bytes, isPartial })
86      } else {
87        described.push({ path, kind: 'file', files: 1, bytes: stat.size, isPartial: false })
88      }
89    } catch {
90      described.push({ path, kind: 'missing' })
91    }
92  }
93  return described
94}
95
96// `next(e)` answers `{ type: 'engine' }` when nothing beneath drew the band:
97// it may only stand alone, never inside another tree.
98function drawnBeneath(rest: RenderElement): RenderElement | null {
99  return rest.type === 'engine' ? null : rest
100}
101
102function questionFor(danger: Danger, targets: TargetInfo[]): string {
103  if (targets.length === 0) return `Команда ${danger.what}. Разрешить?`
104  return `Команда ${danger.what}: ${targets.map(t => t.path).join(', ')}. Разрешить?`
105}
106
107async function ask($: EngineInterface, danger: Danger): Promise<string> {
108  const targets = danger.targets === undefined ? [] : await describeTargets($, danger.targets, home, WALK_LIMIT)
109  const question = questionFor(danger, targets)
110  await update($, pending, () => ({
111    question,
112    what: danger.what,
113    detail: danger.detail,
114    why: danger.why ?? null,
115    targets,
116  }))
117  // Not awaited: the question opens while the alarm plays.
118  $.audio.play({ asset: ALARM }).catch(error => {
119    $.ui.log(`fuse: alarm did not play: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
120  })
121  try {
122    return await $.ui.ask(question, { options: [RUN, RUN_ALWAYS, STOP], header: HEADER })
123  } finally {
124    await update($, pending, () => null)
125  }
126}
127
128export const register: Register = on => {
129  on('session.start', async ($, e, next) => {
130    home = /^(\/Users\/[^/]+|\/home\/[^/]+)/.exec(e.cwd)?.[1]
131    return next(e)
132  })
133
134  // A scheduled task opens with its own marker: nobody is there to answer.
135  on('prompt.submit', async ($, e, next) => {
136    if (e.text.startsWith('<scheduled-task')) await update($, isUnattended, () => true)
137    return next(e)
138  })
139
140  on('tool.call', async ($, e, next) => {
141    // The call's arguments sit on the event itself, beside `tool`.
142    const danger = findDanger(e.tool, e)
143    if (danger === null) return next(e)
144    if ((await read($, allowed)).includes(danger.rule)) return next(e)
145
146    // A scheduled task posts and mails on purpose; it may not destroy anything.
147    if (await read($, isUnattended)) {
148      if (danger.kind === 'outward') return next(e)
149      return { deny: `Предохранитель: в автозадаче это запрещено — команда ${danger.what}. Сделай без этого или остановись и опиши, что нужно.` }
150    }
151
152    let answer: string
153    try {
154      answer = await ask($, danger)
155    } catch {
156      return { deny: `Предохранитель: подтвердить было некому — команда ${danger.what}. Не повторяй без явной просьбы.` }
157    }
158
159    if (answer === RUN) return next(e)
160    if (answer === RUN_ALWAYS) {
161      await update($, allowed, list => [...list, danger.rule])
162      return next(e)
163    }
164    const why = answer === STOP ? '' : ` Пояснение: ${answer}`
165    return { deny: `Пользователь запретил: команда ${danger.what}. Не повторяй без явной просьбы.${why}` }
166  })
167
168  // The fuse's own question gets a line under each choice and, on «Выполнить»,
169  // a preview of the command and everything it touches.
170  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
171    const waiting = await read($, pending)
172    const [first, ...rest] = e.props.questions as Array<Record<string, unknown>>
173    if (waiting === null || first === undefined || first.question !== waiting.question) return next(e)
174
175    const touched = waiting.targets.map(target => `- ${targetLine(target)}`).join('\n')
176    const preview =
177      '```sh\n' + waiting.detail + '\n```' + (touched === '' ? '' : `\n\n**Затронет:**\n${touched}`)
178    const descriptions: Record<string, string> = {
179      [RUN]: waiting.why === null ? 'Модель не объяснила зачем' : `Зачем: ${waiting.why}`,
180      [RUN_ALWAYS]: 'Больше не спрашивать про такое в этой сессии',
181      [STOP]: 'Модель получит отказ и не будет повторять без просьбы',
182    }
183    const options = (first.options as Array<Record<string, unknown>>).map(option => ({
184      ...option,
185      description: descriptions[String(option.label)] ?? '',
186      ...(option.label === RUN ? { preview } : {}),
187    }))
188    return next({ ...e, props: { ...e.props, questions: [{ ...first, options }, ...rest] } })
189  })
190
191  // While the fuse waits for an answer, a red card above the prompt says what is at stake.
192  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
193    const rest = await next(e)
194    const waiting = await read($, pending)
195    if (waiting === null || e.props.hasSurvey) return rest
196
197    const { Box, Text } = $.ui.resolve(e)
198    const others = drawnBeneath(rest)
199    const card = (
200      <Box key="fuse-card" flexDirection="column" borderStyle="round" borderColor="error" paddingX={1} marginBottom={1}>
201        <Text bold color="error">
202          ⛔ Опасно: команда {waiting.what}
203        </Text>
204        {waiting.why === null ? null : (
205          <Text wrap="wrap">
206            <Text dimColor>Зачем: </Text>
207            {waiting.why}
208          </Text>
209        )}
210        {waiting.targets.map(target => (
211          <Text wrap="truncate-end">
212            <Text color="error">• </Text>
213            {targetLine(target)}
214          </Text>
215        ))}
216        <Text dimColor wrap="truncate-end">
217          {waiting.detail}
218        </Text>
219        <Text dimColor>Ответь в вопросе ниже: выполнить, разрешать до конца сессии или запретить.</Text>
220      </Box>
221    )
222    if (others === null) return card
223    return (
224      <Box flexDirection="column" width="100%">
225        {card}
226        {others}
227      </Box>
228    )
229  })
230}
231
hooks/rules.ts 208 lines
1// What the fuse stops, and how it explains it. A rule looks at one tool call
2// and answers what would happen, or null when the call is not its business.
3
4export type Kind = 'destructive' | 'outward'
5
6export type Danger = {
7  rule: string
8  kind: Kind
9  // What happens if the call runs, as a verb phrase: «удалит без корзины».
10  what: string
11  // The command or payload, shortened, to show under the question.
12  detail: string
13  // Why the model makes the call, in its own words (a Bash call's description).
14  why?: string
15  // Paths a recursive rm would delete, as the command writes them.
16  targets?: string[]
17}
18
19type Found = Omit<Danger, 'rule' | 'kind' | 'why'>
20
21type Rule = {
22  id: string
23  kind: Kind
24  check: (tool: string, input: Record<string, unknown>) => Found | null
25}
26
27const DETAIL_LIMIT = 300
28
29function cut(text: string, limit = DETAIL_LIMIT): string {
30  const flat = text.replace(/\s+/g, ' ').trim()
31  return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`
32}
33
34function command(tool: string, input: Record<string, unknown>): string | null {
35  if (tool !== 'Bash') return null
36  return typeof input.command === 'string' ? input.command : null
37}
38
39// Paths a recursive rm may touch freely: throwaway folders.
40const SCRATCH = /^(\/private)?\/tmp\/|\/scratchpad\//
41
42// Quoted text: '…' or "…" with escapes.
43const QUOTED = /(["'])(?:\\.|(?!\1)[^\\])*\1/g
44
45// The commands a shell line runs, each from its first word. Heredoc bodies are
46// dropped and quoted text loses its separators, so a dangerous word inside a
47// message, a commit text or a script fed through `<<EOF` is not a command.
48export function commandsOf(line: string): string[] {
49  const withoutHeredocs = line.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '')
50  const flattened = withoutHeredocs.replace(QUOTED, quoted => quoted.replace(/[;&|\n]/g, ' '))
51  return flattened
52    .split(/&&|\|\||[;|\n]/)
53    .map(part =>
54      part
55        .trim()
56        // Leading env assignments and wrappers that run the next word.
57        .replace(/^(?:\w+=\S*\s+)*(?:(?:time|nohup|exec|command|env|xargs(?:\s+-\S+)*)\s+)*/, ''),
58    )
59    .filter(part => part !== '')
60}
61
62// A Bash rule: `pattern` is tried on each command from its first word.
63function bash(id: string, pattern: RegExp, what: (match: RegExpMatchArray) => Omit<Found, 'detail'> | null): Rule {
64  return {
65    id,
66    kind: 'destructive',
67    check: (tool, input) => {
68      const cmd = command(tool, input)
69      if (cmd === null) return null
70      for (const part of commandsOf(cmd)) {
71        const match = part.match(pattern)
72        if (match === null) continue
73        const found = what(match)
74        if (found !== null) return { ...found, detail: cut(cmd) }
75      }
76      return null
77    },
78  }
79}
80
81// SQL that removes data: DROP, TRUNCATE, or DELETE with no WHERE.
82const DESTRUCTIVE_SQL = /\b(DROP\s+(TABLE|DATABASE|SCHEMA|VIEW)|TRUNCATE(\s+TABLE)?\s+\w|DELETE\s+FROM\s+[\w.`"]+\s*(;|$|\)|'|"))/i
83const SQL_CLIENT = /^(?:psql|mysql|sqlite3|clickhouse(?:-client)?|duckdb)\b/
84
85function sqlIn(input: Record<string, unknown>): string | null {
86  for (const key of ['query', 'sql', 'statement']) {
87    const value = input[key]
88    if (typeof value === 'string' && DESTRUCTIVE_SQL.test(value)) return value
89  }
90  return null
91}
92
93function mcpServer(tool: string): string {
94  const match = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(tool)
95  return match?.[1] ?? tool
96}
97
98export const RULES: readonly Rule[] = [
99  bash('rm-recursive', /^(?:sudo\s+)?rm\s+((?:-\S+(?:\s+|$))*)(.*)$/, match => {
100    if (!/(^|\s)(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$)/.test(match[1] ?? '')) return null
101    const targets = (match[2] ?? '')
102      .trim()
103      .split(/\s+/)
104      .filter(t => t !== '' && !t.startsWith('-'))
105      .map(t => t.replace(/^["']|["']$/g, ''))
106    if (targets.length > 0 && targets.every(t => SCRATCH.test(t))) return null
107    return { what: 'удалит без корзины', targets }
108  }),
109  bash('git-force-push', /^git\s+push\b.*\s(?:--force(?:-with-lease)?|-f)\b/, () => ({
110    what: 'перезапишет историю ветки на сервере — чужие коммиты могут пропасть',
111  })),
112  bash('git-reset-hard', /^git\s+reset\b.*--hard\b/, () => ({ what: 'сотрёт все незакоммиченные изменения' })),
113  bash('git-clean', /^git\s+clean\b.*\s-[a-zA-Z]*f/, () => ({ what: 'удалит все неотслеживаемые файлы — их не вернуть' })),
114  bash('git-discard', /^git\s+(?:checkout\s+--\s|restore\s+(?!--staged)[^-])/, () => ({ what: 'откатит правки, которых нет в коммитах' })),
115  bash('git-branch-delete', /^git\s+branch\b.*\s-D\b/, () => ({ what: 'удалит ветку, даже если она не влита' })),
116  {
117    id: 'pipe-to-shell',
118    kind: 'destructive',
119    check: (tool, input) => {
120      const cmd = command(tool, input)
121      if (cmd === null) return null
122      const flat = cmd.replace(QUOTED, '""')
123      return /\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/.test(flat)
124        ? { what: 'скачает скрипт из интернета и сразу выполнит его, не показав', detail: cut(cmd) }
125        : null
126    },
127  },
128  bash('sudo', /^sudo\s/, () => ({ what: 'выполнит команду с правами администратора' })),
129  bash('cloud-delete', /^(?:kubectl|helm|yc)\b.*\b(?:delete|uninstall|destroy)\b/, () => ({ what: 'удалит ресурс в облаке или кластере' })),
130  {
131    id: 'sql-destructive',
132    kind: 'destructive',
133    check: (tool, input) => {
134      const cmd = command(tool, input)
135      if (cmd === null || !DESTRUCTIVE_SQL.test(cmd)) return null
136      return commandsOf(cmd).some(part => SQL_CLIENT.test(part)) ? { what: 'удалит данные в базе', detail: cut(cmd) } : null
137    },
138  },
139  {
140    id: 'mcp-sql-destructive',
141    kind: 'destructive',
142    check: (tool, input) => {
143      if (!tool.startsWith('mcp__')) return null
144      const sql = sqlIn(input)
145      return sql === null ? null : { what: `удалит данные в базе ${mcpServer(tool)}`, detail: cut(sql) }
146    },
147  },
148  {
149    id: 'mcp-delete',
150    kind: 'destructive',
151    check: tool => {
152      if (!tool.startsWith('mcp__')) return null
153      const action = tool.slice(tool.lastIndexOf('__') + 2)
154      if (/(^|_)(delete|trash|remove_permission|destroy)(_|$)/.test(action)) {
155        return { what: `удалит в ${mcpServer(tool)} (${action})`, detail: action }
156      }
157      if (/(^|_)bulk(_|$)/.test(action)) {
158        return { what: `массово изменит в ${mcpServer(tool)} (${action})`, detail: action }
159      }
160      return null
161    },
162  },
163  {
164    id: 'artifact-delete',
165    kind: 'destructive',
166    check: (tool, input) =>
167      tool === 'Artifact' && input.action === 'delete'
168        ? { what: 'удалит артефакт — ссылка перестанет работать у всех', detail: String(input.url ?? '') }
169        : null,
170  },
171  {
172    id: 'mattermost-post',
173    kind: 'outward',
174    check: (tool, input) => {
175      if (!/mattermost_post_create$/.test(tool)) return null
176      const text = typeof input.message === 'string' ? input.message : JSON.stringify(input)
177      return { what: 'отправит сообщение в Mattermost', detail: cut(text) }
178    },
179  },
180  {
181    id: 'mail-send',
182    kind: 'outward',
183    check: (tool, input) => {
184      if (!tool.startsWith('mcp__') || !/__(send_message|forward|reply)$/.test(tool)) return null
185      const to = input.to ?? input.recipients ?? ''
186      return {
187        what: `отправит письмо${to === '' ? '' : ` (${cut(String(to), 60)})`}`,
188        detail: cut(String(input.subject ?? input.body ?? '')),
189      }
190    },
191  },
192  {
193    id: 'drive-share',
194    kind: 'outward',
195    check: tool => (/drive_share$/.test(tool) ? { what: 'откроет доступ к файлу на Google Drive', detail: tool } : null),
196  },
197]
198
199export function findDanger(tool: string, input: unknown): Danger | null {
200  const fields = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
201  const why = typeof fields.description === 'string' && fields.description.trim() !== '' ? fields.description.trim() : undefined
202  for (const rule of RULES) {
203    const found = rule.check(tool, fields)
204    if (found !== null) return { rule: rule.id, kind: rule.kind, ...found, ...(why === undefined ? {} : { why }) }
205  }
206  return null
207}
208
hooks/targets.ts 41 lines
1import type { TargetInfo } from '../types'
2
3// How the fuse words what an rm would take with it. The walk that counts it
4// lives in register.tsx: `$` may not cross an import.
5
6function formatBytes(bytes: number): string {
7  const units = ['Б', 'КБ', 'МБ', 'ГБ']
8  let value = bytes
9  let unit = 0
10  while (value >= 1024 && unit < units.length - 1) {
11    value /= 1024
12    unit += 1
13  }
14  return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
15}
16
17function plural(n: number, one: string, few: string, many: string): string {
18  const last = n % 10
19  const lastTwo = n % 100
20  if (last === 1 && lastTwo !== 11) return one
21  if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) return few
22  return many
23}
24
25// One line per path for the card and the dialog's preview.
26export function targetLine(target: TargetInfo): string {
27  switch (target.kind) {
28    case 'pattern':
29      return `${target.path} — шаблон, что под него попадёт, решит shell`
30    case 'missing':
31      return `${target.path} — не найден или нет доступа посмотреть`
32    case 'file':
33      return `${target.path} — файл, ${formatBytes(target.bytes ?? 0)}`
34    case 'dir': {
35      const files = target.files ?? 0
36      const more = target.isPartial ? ' (посчитано не всё — внутри больше)' : ''
37      return `${target.path} — папка: ${files.toLocaleString('ru')} ${plural(files, 'файл', 'файла', 'файлов')}, ${formatBytes(target.bytes ?? 0)}${more}`
38    }
39  }
40}
41
types/index.d.ts 33 lines
1// Rule ids the person allowed for the rest of the session.
2export type AllowedRules = string[]
3
4// One path an rm would delete, as found on disk.
5export type TargetInfo = {
6  path: string
7  kind: 'file' | 'dir' | 'missing' | 'pattern'
8  files?: number
9  bytes?: number
10  // The count stopped at its limit, or a folder could not be read.
11  isPartial?: boolean
12}
13
14// The question the fuse is waiting on, for the card above the prompt and the dialog.
15export type PendingDanger = {
16  question: string
17  what: string
18  detail: string
19  why: string | null
20  targets: TargetInfo[]
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    fuse: {
26      allowed: AllowedRules
27      // The session runs a scheduled task: nobody is there to answer.
28      isUnattended: boolean
29      pending: PendingDanger | null
30    }
31  }
32}
33