SLOPSHOPPER

airbag

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

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

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

Плагин 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 airbag@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 .

Правила запрета из настроек

Если вызов и так запрещает правило permissions.deny из настроек (например, Bash(rm -rf*)), предохранитель не спрашивает: «Выполнить» там всё равно не сработает, движок откажет сам. Правила читаются из всех источников настроек: user, project, local, flag, policy. Bash-правило сверяется с каждой командой строки так, как она написана. Поэтому rtk proxy rm -rf или git -C repo reset --hard правило не ловит, и предохранитель по-прежнему задаёт вопрос.

Почему airbag

До 10.10.2026 плагин назывался fuse. Полосу над вводом плагины рисуют по цепочке, и внешний плагин раскладывает всё, что нарисовали внутренние. Судя по десктопу, цепочка идёт по алфавиту имён: в документации этого нет, вывод сделан по скриншотам. fuse оказывался внутри chat-memory и context-weather, и карточка сужалась до места слева от кнопок. Имя airbag по алфавиту идёт первым, поэтому предохранитель должен стать внешним, а карточка — встать отдельной строкой во всю ширину над полосой. Настройка prependPlugins в ~/.claude/settings.json пользовательские плагины не переставляет: это проверено.

В cockpit имя не важно: там предохранитель регистрируется первым, а внутри одного плагина внешним оказывается первый зарегистрированный.

В составе cockpit

Код этого плагина входит в общий плагин cockpit. После правки здесь запусти python3 cockpit/scripts/sync.py из папки plugins.

Source 4 files
hooks/register.tsx 260 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 { deniedBy, findDanger } from './rules'
6import type { Danger } from './rules'
7import { targetLine } from './targets'
8
9const allowed = atom({ plugin: 'airbag', key: 'allowed' } as const, [] as AllowedRules)
10const isUnattended = atom({ plugin: 'airbag', key: 'isUnattended' } as const, false)
11// The question waiting for an answer, for the red card above the prompt.
12const pending = atom({ plugin: 'airbag', 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// The home folder, for `~` in a command (the engine does not expand it).
24function homeOf(cwd: string): string | undefined {
25  return /^(\/Users\/[^/]+|\/home\/[^/]+)/.exec(cwd)?.[1]
26}
27
28// What an rm would take with it: per path, whether it is there, and for a
29// folder how many files and bytes lie under it (counted up to a limit).
30function resolve(path: string, home: string | undefined): string {
31  if (home !== undefined && (path === '~' || path.startsWith('~/'))) return home + path.slice(1)
32  return path
33}
34
35async function walk(
36  $: EngineInterface,
37  dir: string,
38  budget: { left: number },
39): Promise<{ files: number; bytes: number; isPartial: boolean }> {
40  let files = 0
41  let bytes = 0
42  let isPartial = false
43  const queue = [dir]
44  while (queue.length > 0) {
45    if (budget.left <= 0) {
46      isPartial = true
47      break
48    }
49    budget.left -= 1
50    const current = queue.shift()!
51    let entries
52    try {
53      entries = await $.fs.list(current)
54    } catch {
55      isPartial = true
56      continue
57    }
58    for (const entry of entries) {
59      if (entry.kind === 'dir' && !entry.isLink) queue.push(`${current}/${entry.name}`)
60      else {
61        files += 1
62        bytes += entry.size
63      }
64    }
65  }
66  return { files, bytes, isPartial }
67}
68
69export async function describeTargets(
70  $: EngineInterface,
71  targets: readonly string[],
72  home: string | undefined,
73  folderLimit: number,
74): Promise<TargetInfo[]> {
75  const budget = { left: folderLimit }
76  const described: TargetInfo[] = []
77  for (const path of targets) {
78    // A glob or a variable is the shell's to expand: say so rather than guess.
79    if (/[*?$`{[]/.test(path)) {
80      described.push({ path, kind: 'pattern' })
81      continue
82    }
83    try {
84      const stat = await $.fs.stat(resolve(path, home))
85      if (stat.kind === 'dir') {
86        const { files, bytes, isPartial } = await walk($, resolve(path, home), budget)
87        described.push({ path, kind: 'dir', files, bytes, isPartial })
88      } else {
89        described.push({ path, kind: 'file', files: 1, bytes: stat.size, isPartial: false })
90      }
91    } catch {
92      described.push({ path, kind: 'missing' })
93    }
94  }
95  return described
96}
97
98const SETTINGS_SOURCES = ['user', 'project', 'local', 'flag', 'policy'] as const
99
100// Every `permissions.deny` rule, from each settings source on its own: the merge
101// takes a key from one source and could hide the others' rules. A read that
102// fails gives no rules, so the fuse asks rather than stays silent.
103async function denyRules($: EngineInterface): Promise<string[]> {
104  const rules: string[] = []
105  for (const source of SETTINGS_SOURCES) {
106    try {
107      const permissions = (await $.settings.read({ source })).permissions as { deny?: unknown } | undefined
108      const deny = Array.isArray(permissions?.deny) ? permissions.deny : []
109      rules.push(...deny.filter((rule): rule is string => typeof rule === 'string'))
110    } catch (error) {
111      $.ui.log(`airbag: settings ${source} unread: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
112    }
113  }
114  return rules
115}
116
117// `next(e)` answers `{ type: 'engine' }` when nothing beneath drew the band:
118// it may only stand alone, never inside another tree.
119function drawnBeneath(rest: RenderElement): RenderElement | null {
120  return rest.type === 'engine' ? null : rest
121}
122
123function questionFor(danger: Danger, targets: TargetInfo[]): string {
124  if (targets.length === 0) return `Команда ${danger.what}. Разрешить?`
125  return `Команда ${danger.what}: ${targets.map(t => t.path).join(', ')}. Разрешить?`
126}
127
128async function ask($: EngineInterface, danger: Danger): Promise<string> {
129  const targets =
130    danger.targets === undefined ? [] : await describeTargets($, danger.targets, homeOf(await $.session.cwd()), WALK_LIMIT)
131  const question = questionFor(danger, targets)
132  await update($, pending, () => ({
133    question,
134    what: danger.what,
135    detail: danger.detail,
136    why: danger.why ?? null,
137    targets,
138  }))
139  // Not awaited: the question opens while the alarm plays.
140  $.audio.play({ asset: ALARM }).catch(error => {
141    $.ui.log(`airbag: alarm did not play: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
142  })
143  try {
144    return await $.ui.ask(question, { options: [RUN, RUN_ALWAYS, STOP], header: HEADER })
145  } finally {
146    await update($, pending, () => null)
147  }
148}
149
150// No session.start hook: cockpit bundles this module with context-weather's, and
151// one plugin may hook an event only once without a matcher.
152export const register: Register = on => {
153  // A scheduled task opens with its own marker: nobody is there to answer.
154  on('prompt.submit', async ($, e, next) => {
155    if (e.text.startsWith('<scheduled-task')) await update($, isUnattended, () => true)
156    return next(e)
157  })
158
159  on('tool.call', async ($, e, next) => {
160    // The call's arguments sit on the event itself, beside `tool`.
161    const danger = findDanger(e.tool, e)
162    if (danger === null) return next(e)
163    // A settings deny rule refuses it anyway: no question whose «Выполнить» cannot run.
164    if (deniedBy(e.tool, e, await denyRules($)) !== null) return next(e)
165    if ((await read($, allowed)).includes(danger.rule)) return next(e)
166
167    // A scheduled task posts and mails on purpose; it may not destroy anything.
168    if (await read($, isUnattended)) {
169      if (danger.kind === 'outward') return next(e)
170      return { deny: `Предохранитель: в автозадаче это запрещено — команда ${danger.what}. Сделай без этого или остановись и опиши, что нужно.` }
171    }
172
173    let answer: string
174    try {
175      answer = await ask($, danger)
176    } catch {
177      return { deny: `Предохранитель: подтвердить было некому — команда ${danger.what}. Не повторяй без явной просьбы.` }
178    }
179
180    if (answer === RUN) return next(e)
181    if (answer === RUN_ALWAYS) {
182      await update($, allowed, list => [...list, danger.rule])
183      return next(e)
184    }
185    const why = answer === STOP ? '' : ` Пояснение: ${answer}`
186    return { deny: `Пользователь запретил: команда ${danger.what}. Не повторяй без явной просьбы.${why}` }
187  })
188
189  // The fuse's own question gets a line under each choice and, on «Выполнить»,
190  // a preview of the command and everything it touches.
191  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
192    const waiting = await read($, pending)
193    const [first, ...rest] = e.props.questions as Array<Record<string, unknown>>
194    if (waiting === null || first === undefined || first.question !== waiting.question) return next(e)
195
196    const touched = waiting.targets.map(target => `- ${targetLine(target)}`).join('\n')
197    const preview =
198      '```sh\n' + waiting.detail + '\n```' + (touched === '' ? '' : `\n\n**Затронет:**\n${touched}`)
199    const descriptions: Record<string, string> = {
200      [RUN]: waiting.why === null ? 'Модель не объяснила зачем' : `Зачем: ${waiting.why}`,
201      [RUN_ALWAYS]: 'Больше не спрашивать про такое в этой сессии',
202      [STOP]: 'Модель получит отказ и не будет повторять без просьбы',
203    }
204    const options = (first.options as Array<Record<string, unknown>>).map(option => ({
205      ...option,
206      description: descriptions[String(option.label)] ?? '',
207      ...(option.label === RUN ? { preview } : {}),
208    }))
209    return next({ ...e, props: { ...e.props, questions: [{ ...first, options }, ...rest] } })
210  })
211
212  // While the fuse waits for an answer, a red card above the prompt says what is at stake.
213  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
214    const rest = await next(e)
215    const waiting = await read($, pending)
216    if (waiting === null || e.props.hasSurvey) return rest
217
218    const { Box, Text } = $.ui.resolve(e)
219    const others = drawnBeneath(rest)
220    const card = (
221      // Full width: in a band that wraps (context-weather's) the card takes a line of its own.
222      <Box
223        key="fuse-card"
224        flexDirection="column"
225        borderStyle="round"
226        borderColor="error"
227        paddingX={1}
228        marginBottom={1}
229        width="100%"
230        minWidth={0}
231      >
232        <Text bold color="error">
233          ⛔ Опасно: команда {waiting.what}
234        </Text>
235        <Text wrap="wrap">
236          <Text dimColor>Зачем: </Text>
237          {waiting.why ?? <Text dimColor>модель не объяснила</Text>}
238        </Text>
239        {waiting.targets.map(target => (
240          <Text wrap="truncate-end">
241            <Text color="error">• </Text>
242            {targetLine(target)}
243          </Text>
244        ))}
245        <Text dimColor wrap="truncate-end">
246          {waiting.detail}
247        </Text>
248        <Text dimColor>Ответь в вопросе ниже: выполнить, разрешать до конца сессии или запретить.</Text>
249      </Box>
250    )
251    if (others === null) return card
252    return (
253      <Box flexDirection="column" width="100%">
254        {card}
255        {others}
256      </Box>
257    )
258  })
259}
260
hooks/rules.ts 273 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  // Words a description must touch to explain this danger. A Bash description
26  // often sums up the whole compound line («Add README, git status») and says
27  // nothing of the rm in it: then the fuse says the reason is missing.
28  about?: RegExp
29}
30
31const DETAIL_LIMIT = 300
32
33function cut(text: string, limit = DETAIL_LIMIT): string {
34  const flat = text.replace(/\s+/g, ' ').trim()
35  return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`
36}
37
38function command(tool: string, input: Record<string, unknown>): string | null {
39  if (tool !== 'Bash') return null
40  return typeof input.command === 'string' ? input.command : null
41}
42
43// Paths a recursive rm may touch freely: throwaway folders.
44const SCRATCH = /^(\/private)?\/tmp\/|\/scratchpad\//
45
46// Quoted text: '…' or "…" with escapes.
47const QUOTED = /(["'])(?:\\.|(?!\1)[^\\])*\1/g
48
49// Leading env assignments and wrappers that run the next word; `rtk` and
50// `rtk proxy` run the command after them too.
51const WRAPPERS = /^(?:\w+=\S*\s+)*(?:(?:time|nohup|exec|command|env|rtk(?:\s+proxy)?|xargs(?:\s+-\S+)*)\s+)*/
52
53// Git's own options before the subcommand: `git -C "$dir" -c k=v --no-pager branch -D x`
54// is `git branch -D x` to the rules. Each group: -C with its path (quoted or not),
55// -c with its setting, a long option with an optional =value, or -P.
56const GIT_GLOBALS = /^git((?:\s+(?:-C\s+(?:"[^"]*"|'[^']*'|\S+)|-c\s+\S+|--[\w-]+(?:=\S+)?|-P))+)(?=\s)/
57
58// The commands a shell line runs, each from its first word. Heredoc bodies are
59// dropped and quoted text loses its separators, so a dangerous word inside a
60// message, a commit text or a script fed through `<<EOF` is not a command.
61export function commandsOf(line: string): string[] {
62  return rawCommandsOf(line)
63    .map(part => part.replace(WRAPPERS, '').replace(GIT_GLOBALS, 'git'))
64    .filter(part => part !== '')
65}
66
67// The same commands as written, wrappers and git options left in place: what
68// the engine's own permission rules are matched against.
69function rawCommandsOf(line: string): string[] {
70  const withoutHeredocs = line.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '')
71  const flattened = withoutHeredocs.replace(QUOTED, quoted => quoted.replace(/[;&|\n]/g, ' '))
72  return flattened
73    .split(/&&|\|\||[;|\n]/)
74    .map(part => part.trim())
75    .filter(part => part !== '')
76}
77
78// A Bash rule's content as a test on one command: `npm test:*` and `rm -rf*`
79// are prefixes, `*` anywhere is any text, anything else is the whole command.
80function bashRuleMatches(content: string, part: string): boolean {
81  if (content.endsWith(':*')) return part.startsWith(content.slice(0, -2))
82  const pattern = content.split('*').map(piece => piece.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')
83  return new RegExp(`^${pattern}$`, 's').test(part)
84}
85
86// The settings' `permissions.deny` rule that blocks this call anyway, or null.
87// Such a call is the engine's to refuse: asking about it would offer «Выполнить»
88// that cannot run. Bash rules are matched against each command as written, so a
89// command the engine would not match (`rtk proxy rm -rf`) still gets the question.
90export function deniedBy(tool: string, input: unknown, deny: readonly string[]): string | null {
91  const fields = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
92  const cmd = command(tool, fields)
93  for (const rule of deny) {
94    const bashRule = /^Bash\((.*)\)$/s.exec(rule)
95    if (bashRule !== null) {
96      if (cmd !== null && rawCommandsOf(cmd).some(part => bashRuleMatches(bashRule[1] ?? '', part))) return rule
97      continue
98    }
99    if (rule === tool) return rule
100    // An MCP server or a `mcp__server__*` family.
101    const family = rule.endsWith('__*') ? rule.slice(0, -1) : `${rule}__`
102    if (rule.startsWith('mcp__') && tool.startsWith(family)) return rule
103  }
104  return null
105}
106
107// A Bash rule: `pattern` is tried on each command from its first word.
108function bash(
109  id: string,
110  pattern: RegExp,
111  about: RegExp,
112  what: (match: RegExpMatchArray) => Omit<Found, 'detail'> | null,
113): Rule {
114  return {
115    id,
116    kind: 'destructive',
117    about,
118    check: (tool, input) => {
119      const cmd = command(tool, input)
120      if (cmd === null) return null
121      for (const part of commandsOf(cmd)) {
122        const match = part.match(pattern)
123        if (match === null) continue
124        const found = what(match)
125        if (found !== null) return { ...found, detail: cut(cmd) }
126      }
127      return null
128    },
129  }
130}
131
132// SQL that removes data: DROP, TRUNCATE, or DELETE with no WHERE.
133const DESTRUCTIVE_SQL = /\b(DROP\s+(TABLE|DATABASE|SCHEMA|VIEW)|TRUNCATE(\s+TABLE)?\s+\w|DELETE\s+FROM\s+[\w.`"]+\s*(;|$|\)|'|"))/i
134const SQL_CLIENT = /^(?:psql|mysql|sqlite3|clickhouse(?:-client)?|duckdb)\b/
135
136function sqlIn(input: Record<string, unknown>): string | null {
137  for (const key of ['query', 'sql', 'statement']) {
138    const value = input[key]
139    if (typeof value === 'string' && DESTRUCTIVE_SQL.test(value)) return value
140  }
141  return null
142}
143
144function mcpServer(tool: string): string {
145  const match = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(tool)
146  return match?.[1] ?? tool
147}
148
149const REMOVING = /удал|снес|снос|стер|стир|чист|чищ|убра|убер|clean|delet|remov|wipe|purge|\brm\b/i
150const FORCE_PUSH = /push|пуш|forc|перезапис|истори|rebase|amend/i
151const DISCARDING = /reset|сброс|откат|отмен|discard|restor|revert|верн|выбро/i
152const BRANCHES = /ветк|branch|удал|delet|remov|чист|чищ|clean/i
153const INSTALLING = /install|установ|скрипт|script|setup/i
154const SUPERUSER = /sudo|прав|админ|root|install|установ|систем/i
155const CLOUD = /удал|снес|delet|remov|uninstall|destroy|откат|rollback/i
156const DATA = /удал|delet|drop|truncat|чист|чищ|clean|очист|сброс|reset|миграц|migrat/i
157
158export const RULES: readonly Rule[] = [
159  bash('rm-recursive', /^(?:sudo\s+)?rm\s+((?:-\S+(?:\s+|$))*)(.*)$/, REMOVING, match => {
160    if (!/(^|\s)(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$)/.test(match[1] ?? '')) return null
161    const targets = (match[2] ?? '')
162      .trim()
163      .split(/\s+/)
164      .filter(t => t !== '' && !t.startsWith('-'))
165      .map(t => t.replace(/^["']|["']$/g, ''))
166    if (targets.length > 0 && targets.every(t => SCRATCH.test(t))) return null
167    return { what: 'удалит без корзины', targets }
168  }),
169  bash('git-force-push', /^git\s+push\b.*\s(?:--force(?:-with-lease)?|-f)\b/, FORCE_PUSH, () => ({
170    what: 'перезапишет историю ветки на сервере — чужие коммиты могут пропасть',
171  })),
172  bash('git-reset-hard', /^git\s+reset\b.*--hard\b/, DISCARDING, () => ({ what: 'сотрёт все незакоммиченные изменения' })),
173  bash('git-clean', /^git\s+clean\b.*\s-[a-zA-Z]*f/, REMOVING, () => ({ what: 'удалит все неотслеживаемые файлы — их не вернуть' })),
174  bash('git-discard', /^git\s+(?:checkout\s+--\s|restore\s+(?!--staged)[^-])/, DISCARDING, () => ({ what: 'откатит правки, которых нет в коммитах' })),
175  bash('git-branch-delete', /^git\s+branch\b.*\s-D\b/, BRANCHES, () => ({ what: 'удалит ветку, даже если она не влита' })),
176  {
177    id: 'pipe-to-shell',
178    kind: 'destructive',
179    about: INSTALLING,
180    check: (tool, input) => {
181      const cmd = command(tool, input)
182      if (cmd === null) return null
183      const flat = cmd.replace(QUOTED, '""')
184      return /\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/.test(flat)
185        ? { what: 'скачает скрипт из интернета и сразу выполнит его, не показав', detail: cut(cmd) }
186        : null
187    },
188  },
189  bash('sudo', /^sudo\s/, SUPERUSER, () => ({ what: 'выполнит команду с правами администратора' })),
190  bash('cloud-delete', /^(?:kubectl|helm|yc)\b.*\b(?:delete|uninstall|destroy)\b/, CLOUD, () => ({ what: 'удалит ресурс в облаке или кластере' })),
191  {
192    id: 'sql-destructive',
193    kind: 'destructive',
194    about: DATA,
195    check: (tool, input) => {
196      const cmd = command(tool, input)
197      if (cmd === null || !DESTRUCTIVE_SQL.test(cmd)) return null
198      return commandsOf(cmd).some(part => SQL_CLIENT.test(part)) ? { what: 'удалит данные в базе', detail: cut(cmd) } : null
199    },
200  },
201  {
202    id: 'mcp-sql-destructive',
203    kind: 'destructive',
204    check: (tool, input) => {
205      if (!tool.startsWith('mcp__')) return null
206      const sql = sqlIn(input)
207      return sql === null ? null : { what: `удалит данные в базе ${mcpServer(tool)}`, detail: cut(sql) }
208    },
209  },
210  {
211    id: 'mcp-delete',
212    kind: 'destructive',
213    check: tool => {
214      if (!tool.startsWith('mcp__')) return null
215      const action = tool.slice(tool.lastIndexOf('__') + 2)
216      if (/(^|_)(delete|trash|remove_permission|destroy)(_|$)/.test(action)) {
217        return { what: `удалит в ${mcpServer(tool)} (${action})`, detail: action }
218      }
219      if (/(^|_)bulk(_|$)/.test(action)) {
220        return { what: `массово изменит в ${mcpServer(tool)} (${action})`, detail: action }
221      }
222      return null
223    },
224  },
225  {
226    id: 'artifact-delete',
227    kind: 'destructive',
228    check: (tool, input) =>
229      tool === 'Artifact' && input.action === 'delete'
230        ? { what: 'удалит артефакт — ссылка перестанет работать у всех', detail: String(input.url ?? '') }
231        : null,
232  },
233  {
234    id: 'mattermost-post',
235    kind: 'outward',
236    check: (tool, input) => {
237      if (!/mattermost_post_create$/.test(tool)) return null
238      const text = typeof input.message === 'string' ? input.message : JSON.stringify(input)
239      return { what: 'отправит сообщение в Mattermost', detail: cut(text) }
240    },
241  },
242  {
243    id: 'mail-send',
244    kind: 'outward',
245    check: (tool, input) => {
246      if (!tool.startsWith('mcp__') || !/__(send_message|forward|reply)$/.test(tool)) return null
247      const to = input.to ?? input.recipients ?? ''
248      return {
249        what: `отправит письмо${to === '' ? '' : ` (${cut(String(to), 60)})`}`,
250        detail: cut(String(input.subject ?? input.body ?? '')),
251      }
252    },
253  },
254  {
255    id: 'drive-share',
256    kind: 'outward',
257    check: tool => (/drive_share$/.test(tool) ? { what: 'откроет доступ к файлу на Google Drive', detail: tool } : null),
258  },
259]
260
261export function findDanger(tool: string, input: unknown): Danger | null {
262  const fields = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
263  const described = typeof fields.description === 'string' ? fields.description.trim() : ''
264  for (const rule of RULES) {
265    const found = rule.check(tool, fields)
266    if (found === null) continue
267    // A description that never touches this danger does not explain it.
268    const explains = described !== '' && (rule.about === undefined || rule.about.test(described))
269    return { rule: rule.id, kind: rule.kind, ...found, ...(explains ? { why: described } : {}) }
270  }
271  return null
272}
273
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    airbag: {
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