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

Плагин 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 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 .hooks/register.tsx 231 lines1import { 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}
231hooks/rules.ts 208 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}
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}
208hooks/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 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