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

Плагин Claude Code. Перед опасной командой или отправкой наружу останавливает вызов и спрашивает, что делать.
Когда вопрос открывается, звучит короткая сирена sounds/alarm.wav. Звук генерирует sounds/scripts/make_sounds.py из соседнего плагина sounds. Плагин sounds такой вопрос пропускает, чтобы два звука не наложились.
Что видно, пока висит вопрос:
Варианты ответа:
Вопрос задаётся обычным диалогом выбора ($.ui.ask), поэтому работает и в режиме bypass. Подписи и превью мод дописывает в этот же диалог через ui.render на AskUserQuestion. Красить сам диалог движок не даёт, поэтому красная карточка живёт в полосе над вводом.
Правила Bash смотрят только на начало каждой команды в цепочке: после ;, &&, ||, |, переменных окружения, sudo, xargs, env и подобных. Тела heredoc выкидываются, а кавычки не режут строку на команды. Поэтому опасная команда, упомянутая в тексте, сообщении коммита или скрипте внутри heredoc, не срабатывает. Первая версия ловила любое упоминание и останавливала даже правку собственного README.
Цена этого: команды внутри bash -c "…" и $(…) не ловятся.
| Правило | Пример | Что скажет | |
|---|---|---|---|
rm-recursive | rm -rf ~/work/x | удалит без корзины (кроме /tmp и scratchpad) | |
git-force-push | git push --force | перезапишет историю ветки на сервере | |
git-reset-hard | git reset --hard | сотрёт незакоммиченные изменения | |
git-clean | git clean -fd | удалит неотслеживаемые файлы | |
git-discard | git checkout -- ., git restore f | откатит правки в файлах | |
git-branch-delete | git branch -D x | удалит невлитую ветку | |
pipe-to-shell | `curl … \ | bash` | скачает и сразу выполнит скрипт |
sudo | sudo … | команда с правами администратора | |
cloud-delete | kubectl delete, yc … delete | удалит ресурс в облаке | |
sql-destructive, mcp-sql-destructive | DROP, TRUNCATE, DELETE без WHERE | удалит данные в базе | |
mcp-delete | *_delete, *_trash, *_bulk_* в MCP | удалит или массово изменит | |
artifact-delete | удаление артефакта | ссылка перестанет работать | |
mattermost-post | пост в Mattermost | отправит сообщение | |
mail-send | send_message, forward, reply | отправит письмо | |
drive-share | drive_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 правило не ловит, и предохранитель по-прежнему задаёт вопрос.
До 10.10.2026 плагин назывался fuse. Полосу над вводом плагины рисуют по цепочке, и внешний плагин раскладывает всё, что нарисовали внутренние. Судя по десктопу, цепочка идёт по алфавиту имён: в документации этого нет, вывод сделан по скриншотам. fuse оказывался внутри chat-memory и context-weather, и карточка сужалась до места слева от кнопок. Имя airbag по алфавиту идёт первым, поэтому предохранитель должен стать внешним, а карточка — встать отдельной строкой во всю ширину над полосой. Настройка prependPlugins в ~/.claude/settings.json пользовательские плагины не переставляет: это проверено.
В cockpit имя не важно: там предохранитель регистрируется первым, а внутри одного плагина внешним оказывается первый зарегистрированный.
Код этого плагина входит в общий плагин cockpit. После правки здесь запусти python3 cockpit/scripts/sync.py из папки plugins.
hooks/register.tsx 260 lines1import { 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}
260hooks/rules.ts 273 lines1// 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}
273hooks/targets.ts 41 lines1import 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}
41types/index.d.ts 33 lines1// 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