Garde-fou : suspend les commandes Bash sensibles et demande Proceed / Cancel dans un panneau avant exécution.

Un mod pour Claude Code qui suspend les commandes Bash sensibles et demande une validation explicite avant de les exécuter.
Quand Claude lance une commande destructrice ou irréversible, l'appel est mis en attente et un panneau « Sensitive guard » s'ouvre. Il affiche la commande et les raisons du blocage. Proceed (touche 1) exécute la commande telle quelle, Cancel (touche 2) la refuse et le signale à Claude, qui doit alors proposer une alternative plus sûre. Si le terminal est trop étroit pour placer le panneau, le même contenu s'affiche au-dessus du prompt. Les commandes ordinaires passent sans rien afficher.
rm -r, rm -f)git reset --hard, git clean -f, git push --force, git branch -D, git checkout -- ., git restore .DROP TABLE, DROP DATABASE, DROP SCHEMA, TRUNCATE TABLE)terraform apply et terraform destroy, kubectl delete, docker system prune, docker volume prunechmod -R et chown -Rdd of=/dev/..., mkfs)curl ... | sh)sudoLes règles sont la liste RULES en tête de hooks/register.ts : ajoutez ou retirez une ligne { label, pattern } pour adapter le garde-fou à votre pile.
Dans une session Claude Code, à saisir au prompt du terminal, une seule commande suffit :
/plugin install sensitive-guard --marketplace G1TS23/sensitive-guard
Claude Code demande alors Add marketplace? : répondez y, puis choisissez la portée (utilisateur pour l'avoir dans toutes vos sessions, ou projet). Le message Installed sensitive-guard. Plugin is now active. confirme l'installation, et les hooks sont actifs tout de suite, sans rechargement.
Cette commande est celle d'un terminal : l'onglet Code de l'application desktop répond qu'elle n'y est pas disponible. Installez d'abord le mod depuis un terminal avec la portée utilisateur : il se charge ensuite aussi dans les sessions locales de l'application desktop.
Variante en deux étapes, équivalente :
/plugin marketplace add G1TS23/sensitive-guard
/plugin install sensitive-guard@sensitive-guard
Si le mod n'apparaît pas, rechargez avec /reload-plugins ou redémarrez Claude Code.
Pour l'essayer sans l'installer, depuis le dossier cloné :
claude --plugin-dir ./sensitive-guard
Nécessite Claude Code 2.1.287 ou plus récent.
Le mod lit le texte de la commande. Un alias, un script qui appelle rm en interne ou une substitution comme $(...) peut lui échapper : c'est un filet de sécurité, pas un blocage strict. Pour un refus définitif, ajoutez des règles de permission deny dans les réglages de Claude Code. Comme tout mod, il s'exécute avec le même accès à votre machine que Claude Code lui-même et sans bac à sable : relisez le code avant de l'installer.
claude plugin validate .
claude plugin test .
Les tests simulent le moteur (placement du panneau, attente, pression des boutons) mais ne reproduisent pas le rendu réel : vérifiez l'affichage dans une vraie session.
hooks/register.ts 133 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Held } from '../types'
5
6const PANE = 'sensitive-guard'
7const held = atom({ plugin: 'sensitive-guard', key: 'held' } as const, null)
8
9type Rule = { label: string; pattern: RegExp }
10
11// Chaque règle décrit une famille de commandes difficiles ou impossibles à annuler.
12const RULES: Rule[] = [
13 { label: 'suppression récursive ou forcée (rm -r / -f)', pattern: /\brm\s+(?:[^|;&]*\s)?-[a-zA-Z]*[rRf]/ },
14 { label: 'git reset --hard (perte des modifications locales)', pattern: /\bgit\s+reset\s+(?:[^|;&]*\s)?--hard\b/ },
15 { label: 'git clean forcé (suppression de fichiers non suivis)', pattern: /\bgit\s+clean\s+(?:[^|;&]*\s)?-[a-zA-Z]*f/ },
16 { label: "git push --force (réécriture de l'historique distant)", pattern: /\bgit\s+push\s+(?:[^|;&]*\s)?(?:--force\b|-f\b)/ },
17 { label: 'git branch -D (suppression de branche non fusionnée)', pattern: /\bgit\s+branch\s+(?:[^|;&]*\s)?-D\b/ },
18 { label: 'git checkout/restore sur tout le dépôt', pattern: /\bgit\s+(?:checkout\s+--\s+\.|restore\s+(?:--\S+\s+)*\.)(?:\s|$)/ },
19 { label: 'SQL destructeur (DROP / TRUNCATE)', pattern: /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b/i },
20 { label: 'terraform apply/destroy', pattern: /\bterraform\s+(?:apply|destroy)\b/ },
21 { label: 'kubectl delete', pattern: /\bkubectl\s+delete\b/ },
22 { label: 'docker system prune / volume prune', pattern: /\bdocker\s+(?:system|volume)\s+prune\b/ },
23 { label: 'chmod/chown récursif', pattern: /\b(?:chmod|chown)\s+(?:[^|;&]*\s)?-R\b/ },
24 { label: 'écriture directe sur un périphérique bloc', pattern: /\b(?:dd\s+[^|;&]*of=\/dev\/|mkfs(?:\.\w+)?\s|>\s*\/dev\/(?:sd|nvme|disk))/ },
25 { label: "exécution d'un script distant (curl | sh)", pattern: /\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/ },
26 { label: 'élévation de privilèges (sudo)', pattern: /(?:^|[;&|]\s*)sudo\s/ },
27]
28
29function classify(command: string): string[] {
30 return RULES.filter(rule => rule.pattern.test(command)).map(rule => rule.label)
31}
32
33// La décision est lue par la boucle d'attente du hook et écrite par les boutons : elle vit dans le module,
34// le temps d'un appel suspendu. L'affichage, lui, vient de $.state (atome `held`).
35const gate: { decision: 'proceed' | 'cancel' | null; busy: boolean } = { decision: null, busy: false }
36
37const short = (text: string) => (text.length > 400 ? `${text.slice(0, 400)}…` : text)
38
39// Le même contenu s'affiche dans le panneau, ou au-dessus du prompt si le terminal est trop étroit.
40function draw($: any, e: any, h: Held) {
41 const { Box, Button, Text } = $.ui.resolve(e)
42
43 return Box({
44 flexDirection: 'column',
45 paddingX: 1,
46 borderStyle: 'round',
47 borderColor: 'yellow',
48 children: [
49 Text({ bold: true, color: 'yellow', children: 'Commande sensible suspendue' }),
50 Text({ children: short(h.command) }),
51 Text({ dimColor: true, children: h.hits.map(label => `- ${label}`).join('\n') }),
52 Box({
53 flexDirection: 'row',
54 children: [
55 Button({
56 key: 'proceed',
57 label: 'Proceed',
58 hotkey: '1',
59 variant: 'primary',
60 onPress: async () => {
61 gate.decision = 'proceed'
62 },
63 }),
64 Text({ children: ' ' }),
65 Button({
66 key: 'cancel',
67 label: 'Cancel',
68 hotkey: '2',
69 onPress: async () => {
70 gate.decision = 'cancel'
71 },
72 }),
73 ],
74 }),
75 ],
76 })
77}
78
79export const register: Register = on => {
80 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
81 const command = String(e.command ?? '')
82 const hits = classify(command)
83 if (hits.length === 0) return next(e)
84
85 // Un seul panneau à la fois : les appels sensibles suivants attendent leur tour.
86 while (gate.busy && !next.signal.aborted) await $.process.run(['sleep', '0.25'])
87 if (next.signal.aborted) return { deny: `${$.plugin.name} : appel interrompu avant la validation.` }
88
89 gate.busy = true
90 gate.decision = null
91 try {
92 const entry: Held = { command, hits, where: 'pane' }
93 await update($, held, () => entry)
94 $.ui.toast('sensitive-guard : commande sensible en attente de votre décision')
95
96 const opened = await $.ui.open({ id: PANE, title: 'Sensitive guard', focus: true })
97 if (!opened.isPlaced) await update($, held, h => (h ? { ...h, where: 'band' } : h))
98
99 // Le temps passé dans les appels $ ne compte pas dans le budget du hook : on attend par petits sleeps.
100 while (gate.decision === null && !next.signal.aborted) await $.process.run(['sleep', '0.25'])
101
102 if (gate.decision === 'proceed') return await next(e)
103
104 return {
105 deny:
106 `${$.plugin.name} : l'utilisateur a refusé (Cancel) cette commande : ${hits.join(' ; ')}. ` +
107 `Ne la relance pas telle quelle ; propose une alternative plus sûre ou demande-lui comment procéder.`,
108 }
109 } finally {
110 await update($, held, () => null)
111 await $.ui.close({ id: PANE })
112 gate.decision = null
113 gate.busy = false
114 }
115 }).catch(($, e, next) =>
116 next.called ? next(e) : { deny: `${$.plugin.name} : le garde-fou a échoué, commande refusée par précaution.` },
117 )
118
119 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
120 const h = await read($, held)
121 if (h === null) return next(e)
122
123 return draw($, e, h)
124 })
125
126 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
127 const h = await read($, held)
128 if (h === null || h.where !== 'band' || e.props.hasSurvey) return next(e)
129
130 return draw($, e, h)
131 })
132}
133types/index.d.ts 13 lines1export type Held = {
2 command: string
3 hits: string[]
4 // 'pane' : affichée dans le panneau latéral ; 'band' : repli au-dessus du prompt si le panneau n'est pas placé.
5 where: 'pane' | 'band'
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'sensitive-guard': { held: Held | null }
11 }
12}
13