SLOPSHOPPER

sensitive-guard

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

newpanebandguardtoastprocess
v0.2.0no licenseupdated 2026-10-09G1TS23/sensitive-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sensitive-guard
│ ┃ Sensitive guard ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╭──────────────────────────────────────────╮ │ sensitive-guard │ │ ┃ │ Commande sensible suspendue │ ⏺ Read(src/auth.ts) │ sensitive-guard : commande sensible en │ │ ┃ │ rm -rf build && git push --force origin │ ⎿ Read 6 lines │ attente de votre décision │ │ ┃ │ main │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ │ - suppression récursive ou forcée (rm -r │ ⎿ Added 2 lines, removed 1 line │ ┃ │ / -f) - git push --force (réécriture de │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ l'historique distant) │ ⎿ Denied by sensitive-guard: sensitive-guard : le garde-fou │ ┃ │ [ Proceed ] [ Cancel ] │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sensitive guard · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ Commande sensible suspendue │ │ rm -rf build && git push --force origin main │ │ - suppression récursive ou forcée (rm -r / -f) - git push │ │ --force (réécriture de l'historique distant) │ │ [ Proceed ] [ Cancel ] │ ╰──────────────────────────────────────────────────────────╯
README

sensitive-guard

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.

Commandes surveillées

  • suppressions récursives ou forcées (rm -r, rm -f)
  • git reset --hard, git clean -f, git push --force, git branch -D, git checkout -- ., git restore .
  • SQL destructeur (DROP TABLE, DROP DATABASE, DROP SCHEMA, TRUNCATE TABLE)
  • terraform apply et terraform destroy, kubectl delete, docker system prune, docker volume prune
  • chmod -R et chown -R
  • écriture directe sur un périphérique bloc (dd of=/dev/..., mkfs)
  • exécution d'un script distant (curl ... | sh)
  • sudo

Les 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.

Installation

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.

Limites

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.

Développement

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.

Source 2 files
hooks/register.ts 133 lines
1import { 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}
133
types/index.d.ts 13 lines
1export 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