SLOPSHOPPER

shell-guard

Claude Code skill that builds, debugs, audits and packages Claude Code mods from a plain-language description

newguardtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shell-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by shell-guard: The user declined this recursive force delete. Ask before trying a different approa ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

shell-guard

Leia em português

A safety net that stops dangerous commands until you say yes, and keeps Claude away from files that hold passwords and keys.

What is a mod?

Claude Code is Anthropic's assistant for working with code, in the terminal or in the Claude desktop app. A mod is a small add-on for it: it can add a panel, a bar above the box where you type, a new /command, or a rule that checks what Claude is about to do. This folder is one complete mod you can install and use as it is, or copy as a starting point.

What it does

Claude can run commands on your computer. Most are harmless, but a few can delete a folder for good, throw away work that was not saved in Git, or rewrite history that other people share. This mod watches every command before it runs. When it sees one of those risky patterns it pauses and asks you, in plain words, what the command would do. It also refuses any edit to files that usually hold secrets, such as .env files and private keys.

What you see

  • When a risky command comes up: a question with the reason ("force push (rewrites shared history). Run this command?"), the full command, and two choices, Run it and Refuse.
  • In block mode: a short notice ("Blocked: force push") instead of the question.

How to use it

  1. Install the mod. It works on its own; there is nothing to open.
  2. When the question appears, read the command and choose Run it or Refuse.
  3. If you would rather never be asked, set the mode to block (always refuse) in the settings.

What counts as risky

  • Deleting folders with force (rm -rf, Remove-Item -Recurse -Force, rd /s)
  • Force push in Git (git push --force, -f, +branch)
  • git reset --hard and git clean -f (throw away unsaved or untracked work)
  • Database commands that drop or empty tables (DROP TABLE, TRUNCATE)
  • Changing permissions of many files at once (chmod -R, chown -R)
  • Formatting a disk (mkfs, format, Format-Volume, Clear-Disk)

Files it protects from edits

.env and .env.something, id_rsa, id_ed25519, files ending in .pem, .key, .p12, .pfx, and credentials / credentials.json.

Keyboard shortcuts

KeyWhat it does
Arrow keys and EnterChoose Run it or Refuse in the question

Commands

None. It works on its own.

Settings

Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.

SettingWhat it meansDefault
mode (Risky command mode)ask: pause and ask you. block: always refuse. off: guard turned off.ask
language (Language)Language of the text the mod shows: en (English) or pt-BR (Brazilian Portuguese).en

Good to know

  • If the question cannot be shown for some reason, the command is refused. When in doubt, it says no.
  • It checks the shell, PowerShell and Monitor tools, on Windows, macOS and Linux.
  • It is a safety net, not a lock. It recognizes common ways of writing these commands; an unusual spelling can slip through. Keep your normal Claude Code permission settings as well.
  • Mode off turns everything off, including the protection of secret files.

Install

You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.

  1. Copy this folder anywhere on your computer.
  2. Inside the copy, rename the folder _claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.
  3. Start Claude Code with the mod:
claude --plugin-dir path/to/shell-guard

To keep it installed for good, ask the claude-mod-forge skill to package it ("package this mod").

Check it

From the skill folder (skills/claude-mod-forge), this runs the manifest check, a strict type check and the 16 automated tests of this mod:

node scripts/check-mod.mjs examples/shell-guard

The tests cover the mod's logic and what it draws in the terminal and the desktop app. How it looks in your own window is worth a quick look in a real session.

For developers: the examples table lists the hooks and API calls this mod demonstrates.

Source 3 files
hooks/register.ts 46 lines
1import type { Register } from 'claude-code'
2
3import { strings } from './i18n'
4import { failureAnswer, isSecretPath, riskOf } from './rules'
5
6// Every guard here fails CLOSED: if the hook throws or times out before deciding,
7// the .catch handler refuses. A guard without .catch would let the command through.
8// What the user sees follows options.language; deny reasons stay English (Claude reads them).
9export const register: Register = (on, options) => {
10  const mode = options.mode === 'block' || options.mode === 'off' ? options.mode : 'ask'
11  const t = strings(options.language)
12
13  // One guard for every tool that runs a command. Monitor's command is optional.
14  on('tool.call', { tool: ['Bash', 'PowerShell', 'Monitor'] }, async ($, e, next) => {
15    if (mode === 'off') return next(e)
16    const risk = riskOf(String(e.command ?? ''))
17    if (risk === undefined) return next(e)
18
19    if (mode === 'block') {
20      $.ui.toast(t.blocked(t.label(risk.label)))
21      return { deny: `${$.plugin.name} blocked a ${risk.label} (${risk.why}). Ask the user before trying another way.` }
22    }
23
24    // Start from the safe answer: a dismissed question, or a claude -p run, refuses.
25    let answer = t.refuse
26    try {
27      // The wait happens inside a $ call, so it does not count against the hook's time limit.
28      const question = t.question(t.label(risk.label), t.why(risk.why), String(e.command ?? '').slice(0, 400))
29      answer = await $.ui.ask(question, [t.runIt, t.refuse])
30    } catch {
31      // Nobody could answer.
32    }
33    if (answer !== t.runIt) {
34      return { deny: `The user declined this ${risk.label}. Ask before trying a different approach.` }
35    }
36    // Approved here only means "go on": the normal permission check still runs after next().
37    return next(e)
38  }).catch(($, e, next) => failureAnswer($.plugin.name, next.called, 'command') ?? next(e))
39
40  on('tool.call', { tool: ['Edit', 'Write'] }, ($, e, next) =>
41    mode !== 'off' && isSecretPath(e.file_path)
42      ? { deny: `${$.plugin.name}: ${e.file_path} holds secrets and is not edited by Claude here.` }
43      : next(e),
44  ).catch(($, e, next) => failureAnswer($.plugin.name, next.called, 'edit') ?? next(e))
45}
46
hooks/i18n.ts 54 lines
1// The text the USER sees (question, options, toast), per language. Pure: no $ calls.
2// Anything other than a known language falls back to English.
3// Deny reasons are not here on purpose: Claude reads them, so they stay in English.
4
5export type Language = 'en' | 'pt-BR'
6
7const en = {
8  // Risk labels and reasons come from rules.ts in English; a table maps them for display.
9  label: (english: string) => english,
10  why: (english: string) => english,
11  question: (label: string, why: string, command: string) => `${label} (${why}). Run this command?\n\n${command}`,
12  runIt: 'Run it',
13  refuse: 'Refuse',
14  blocked: (label: string) => `Blocked: ${label}`,
15}
16
17const LABELS: Record<string, string> = {
18  'recursive force delete': 'exclusão recursiva forçada',
19  'force push': 'push forçado',
20  'hard reset': 'reset --hard',
21  'git clean': 'git clean',
22  'destructive SQL': 'SQL destrutivo',
23  'recursive permission change': 'mudança recursiva de permissões',
24  'disk format': 'formatação de disco',
25  'recursive delete': 'exclusão recursiva',
26}
27
28const WHYS: Record<string, string> = {
29  'deletes files with no undo': 'apaga arquivos sem como desfazer',
30  'rewrites shared history': 'reescreve o histórico compartilhado',
31  'drops uncommitted work': 'descarta trabalho não commitado',
32  'deletes untracked files': 'apaga arquivos não rastreados',
33  'removes data': 'remove dados',
34  'changes many files at once': 'altera muitos arquivos de uma vez',
35  'erases a volume': 'apaga um volume',
36  'deletes a folder tree': 'apaga uma árvore de pastas',
37}
38
39const ptBR: typeof en = {
40  label: english => LABELS[english] ?? english,
41  why: english => WHYS[english] ?? english,
42  question: (label, why, command) => `${label} (${why}). Executar este comando?\n\n${command}`,
43  runIt: 'Executar',
44  refuse: 'Recusar',
45  blocked: label => `Bloqueado: ${label}`,
46}
47
48export type Strings = typeof en
49const TABLE: Record<Language, Strings> = { en, 'pt-BR': ptBR }
50
51export function strings(language: unknown): Strings {
52  return language === 'pt-BR' ? TABLE['pt-BR'] : TABLE.en
53}
54
hooks/rules.ts 49 lines
1// Pure rules, kept apart from the hooks so tests can cover them directly.
2// Every tool that runs commands must be guarded: Bash, PowerShell (Windows) and Monitor.
3// List them from the tools types (claude-code-tools) for the build you target.
4// A regex on command text is a reminder layer, not a sandbox: spellings it misses get through.
5// Pair it with permission rules (deny/ask) and branch protection for anything that must hold.
6
7export type Risk = { label: string; why: string }
8
9const RISKS: { pattern: RegExp; label: string; why: string }[] = [
10  // rm with a recursive AND a force flag, in any order or spelling, within the same command:
11  // -rf, -fr, -r -f, -f -r, -R -f, --recursive --force; a flag may touch a quote: rm -rf"$DIR".
12  {
13    pattern: /\brm\b(?=[^|;&\n]*\s(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(?=[\s"']|$))(?=[^|;&\n]*\s(?:-[a-zA-Z]*f[a-zA-Z]*|--force)(?=[\s"']|$))/,
14    label: 'recursive force delete',
15    why: 'deletes files with no undo',
16  },
17  // --force, --force-with-lease, -f, or a +refspec, quoted or not (git push origin "+main" forces that branch).
18  { pattern: /\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b|\s["']?\+[^\s+"'])/i, label: 'force push', why: 'rewrites shared history' },
19  { pattern: /\bgit\s+reset\s+--hard\b/i, label: 'hard reset', why: 'drops uncommitted work' },
20  { pattern: /\bgit\s+clean\s+-[a-z]*f/i, label: 'git clean', why: 'deletes untracked files' },
21  { pattern: /\b(drop|truncate)\s+(table|database|schema)\b/i, label: 'destructive SQL', why: 'removes data' },
22  { pattern: /\b(chmod|chown)\s+-R\b/i, label: 'recursive permission change', why: 'changes many files at once' },
23  { pattern: /\b(mkfs|format)\b/i, label: 'disk format', why: 'erases a volume' },
24  // PowerShell / cmd (Windows): the PowerShell tool runs commands too, not only Bash.
25  { pattern: /\bRemove-Item\b(?=.*\s-Recurse\b)(?=.*\s-Force\b)/i, label: 'recursive force delete', why: 'deletes files with no undo' },
26  { pattern: /\b(rd|rmdir)\s+\/s\b/i, label: 'recursive delete', why: 'deletes a folder tree' },
27  { pattern: /\b(Format-Volume|Clear-Disk|Initialize-Disk)\b/i, label: 'disk format', why: 'erases a volume' },
28]
29
30export function riskOf(command: string): Risk | undefined {
31  const hit = RISKS.find(r => r.pattern.test(command))
32  return hit && { label: hit.label, why: hit.why }
33}
34
35// Both separators: Windows paths use backslashes.
36const SECRET_FILE = /(^|[\\/])(\.env(\.[\w-]+)?|id_(rsa|ed25519)(\.pub)?|[^\\/]+\.(pem|key|p12|pfx)|credentials(\.json)?)$/i
37
38export function isSecretPath(path: string): boolean {
39  return SECRET_FILE.test(path)
40}
41
42// What the guards' .catch answers. Kept pure so the fail-closed decision is tested directly:
43// before next() was called the guard refuses (undefined would let the call through);
44// after next() it hands back what that call already settled to (the caller returns next(e)).
45export function failureAnswer(plugin: string, nextWasCalled: boolean, what: 'command' | 'edit'): { deny: string } | undefined {
46  if (nextWasCalled) return undefined
47  return { deny: `${plugin}: the check failed, so the ${what === 'command' ? 'command was not run' : 'edit was not made'}.` }
48}
49