SLOPSHOPPER

cmd-guard

Blocks destructive shell commands (rm -rf /, force push, DROP TABLE...)

newguardcommandtoaststatusprompt
v0.2.2MITupdated 2026-10-09redjackfred/claude-code-mods/cmd-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cmd-guard
› fix the failing auth test and add an audit log call ╭──────────────────────────────╮ │ cmd-guard │ ⏺ Read(src/auth.ts) │ 🛡 Blocked · git push │ ⎿ Read 6 lines │ --force │ ⏺ Update(src/auth.ts) ╰──────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by cmd-guard: cmd-guard: the user blocked this command (git push --force: Overwrites remote history ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ cmd-guard: 🛡 Command guard OFF for this session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cmd-guard: 󰒙 guard off
README

claude-code-mods

A few mods for Claude Code, packaged as a plugin marketplace.

ModWhat it does
pomodoroA pixel-art pomodoro timer in a side pane. Day and night skies, animated sun, clouds, stars and meteors, a grass field and a daily tomato tally. Toast and chime when a phase ends.
agent-progressLive Powerline-style progress bars for running subagents, with a moving glint and the model each one runs on.
cmd-guardStops destructive shell commands (rm -rf on /, ~ or *, force pushes, hard resets, dropping tables, mkfs, dd…) and asks what to do: refuse, allow once, trust for this session, or use a safer alternative.
model-routerRuns Explore subagents on haiku and general-purpose subagents on sonnet to save cost. An explicit model on the Agent call always wins; forks and workflows are left alone. Optionally lets Jev pick the model per task.

<img src="docs/pomodoro-focus.png" alt="Pomodoro focus mode: day sky with sun and clouds" width="280"> &nbsp; <img src="docs/pomodoro-break.png" alt="Pomodoro break mode: night sky with moon and stars" width="280">

Install

claude plugin marketplace add redjackfred/claude-code-mods
claude plugin install pomodoro@claude-code-mods
claude plugin install agent-progress@claude-code-mods
claude plugin install cmd-guard@claude-code-mods
claude plugin install model-router@claude-code-mods

Install only the ones you want. Restart Claude Code afterwards.

Usage

pomodoro

/focus [minutes]   start a focus session (default 25)
/focus skip        skip to the next phase
/focus stop        stop the timer
/focus show|hide   show or hide the side pane
  • Runs entirely locally: the timer never calls the model and costs no tokens.
  • Best in a terminal with a Nerd Font and Unicode 13 sextant glyphs (Ghostty, WezTerm, Kitty, iTerm2…).
  • The chime plays through afplay, so sound is macOS only.

agent-progress

agent-progress: three subagents running with Powerline progress bars

/agent-progress toggles the bars on and off.

cmd-guard

cmd-guard asking what to do with a broad rm -rf

Works automatically; /guard off turns it off for the session and /guard on back on. When nobody answers the prompt (or it fails), the command is blocked.

The dialog speaks the language you mostly write in during the session: English, or Traditional Chinese (繁體中文).

It matches commands with regexes, not a shell parser, so treat it as a seatbelt against slips rather than a sandbox: a determined command can still get past it. It also can't tell running a command from mentioning one, so a commit message or heredoc that only quotes a risky command gets stopped too. Reword the text, or /guard off for a moment.

model-router

<img src="docs/model-router.png" alt="model-router toasts: Explore routed to haiku, general-purpose to sonnet" width="360">

Works automatically and shows a toast for each subagent it reroutes. To steer the main agent, you can add this to your ~/.claude/CLAUDE.md:

A model-router mod runs Explore subagents on haiku and general-purpose ones on sonnet
unless the Agent call names a model. For complex reasoning, large refactors, or
hard-to-find bugs, pass `model: "opus"` on the Agent call.
Optional: let Jev pick the model

Jev is a fast "System One" decision model. With it on, each general-purpose subagent's task is classified as haiku, sonnet or opus work before it starts. In a quick test it took about 0.7 s per call and costs well under a cent.

export TYPESAFE_API_KEY=...   # then restart Claude Code
/router jev on                # /router jev off to stop
  • Privacy: it's off by default because turning it on sends each general-purpose task (its description plus the first 4000 characters of the prompt) to TypeSafe's API.
  • Confidence: Jev's pick is used only when its confidence is at least 0.8.
  • Fallback: if Jev is slow (over 1.5 s), fails, or isn't sure, the subagent falls back to sonnet.
  • Explore subagents always stay on haiku.
  • Toast: shows Jev's pick, e.g. general-purpose → haiku · jev 1.00.

Development

Each mod is a Claude Code plugin with TypeScript hooks in hooks/.

claude plugin validate .        # the marketplace
claude plugin validate pomodoro # one plugin
claude plugin test pomodoro     # its tests

License

MIT

Source 3 files
hooks/register.ts 107 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { check, langOf } from './rules'
5import type { Lang, Rule } from './rules'
6
7// session-only on purpose: the guard comes back on, and forgets allowances, in every new session
8const off = atom({ plugin: 'cmd-guard', key: 'off' } as const, false)
9const allowed = atom({ plugin: 'cmd-guard', key: 'allowed' } as const, [])
10// how many of the session's prompts were in each language; the dialog speaks the majority's
11const prompts = atom({ plugin: 'cmd-guard', key: 'prompts' } as const, { en: 0, zh: 0, last: 'en' })
12
13type Choice = 'block' | 'once' | 'session' | 'safer'
14const CHOICES: Choice[] = ['block', 'once', 'session', 'safer']
15const UI = {
16  en: {
17    labels: { block: 'Block (recommended)', once: 'Allow once', session: 'Trust for this session', safer: 'Use the safer alternative' },
18    impact: 'Impact', safer: 'Instead', ask: 'What should happen?',
19    allowedOnce: 'Allowed once', trusted: 'Trusted for this session', blocked: 'Blocked',
20  },
21  zh: {
22    labels: { block: '拒絕執行(建議)', once: '僅允許此次', session: '本工作階段信任此類', safer: '改用安全替代方案' },
23    impact: '影響', safer: '替代', ask: '要如何處理?',
24    allowedOnce: '已放行一次', trusted: '本工作階段信任', blocked: '已攔截',
25  },
26} as const
27
28export const sessionLang = (p: { en: number; zh: number; last: string }): Lang =>
29  p.zh > p.en ? 'zh' : p.en > p.zh ? 'en' : p.last === 'zh' ? 'zh' : 'en'
30
31export const question = (rule: Rule, command: string, lang: Lang) => {
32  const t = rule.text[lang]
33  const ui = UI[lang]
34  const cmd = command.length > 120 ? `${command.slice(0, 117)}...` : command
35  return [
36    `🛡 ${rule.level} · ${t.name}`,
37    `$ ${cmd}`,
38    `${ui.impact}: ${t.impact}`,
39    `${ui.safer}: ${t.safer}`,
40    ui.ask,
41  ].join('\n')
42}
43
44// ask in the engine's own dialog; anything but an explicit choice blocks.
45// Returns the choice, or the text the user typed under "Other".
46const ask = async ($: Parameters<Parameters<Register>[0]>[2] extends never ? never : any, rule: Rule, command: string, lang: Lang): Promise<Choice | string> => {
47  const labels = UI[lang].labels
48  try {
49    const answer: string = await $.ui.ask(question(rule, command, lang), { header: '🛡 cmd-guard', options: CHOICES.map(c => labels[c]) })
50    return CHOICES.find(c => labels[c] === answer) ?? answer
51  } catch {
52    return 'block' // dismissed, or nobody to ask
53  }
54}
55
56export const register: Register = on => {
57  on('session.start', async ($, e, next) => {
58    await $.command.register({ name: 'guard', description: 'Toggle the destructive-command guard for this session (on | off)' })
59    return next(e)
60  })
61
62  on('prompt.submit', async ($, e, next) => {
63    const lang = langOf(e.text)
64    await update($, prompts, p => ({ ...p, [lang]: p[lang] + 1, last: lang }))
65    return next(e)
66  })
67
68  on('command.run', { command: 'guard' }, async ($, e) => {
69    const arg = e.args.trim()
70    const value = arg === 'off' ? true : arg === 'on' ? false : !(await read($, off))
71    await update($, off, () => value)
72    if (!value) await update($, allowed, () => [])
73    $.ui.status(value ? '󰒙 guard off' : undefined)
74    return { text: `🛡 Command guard ${value ? 'OFF for this session' : 'ON · session allowances cleared'}` }
75  })
76
77  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
78    const rule = check(e.command)
79    if (!rule || (await read($, off)) || (await read($, allowed)).includes(rule.id)) return next(e)
80
81    const lang = sessionLang(await read($, prompts))
82    const ui = UI[lang]
83    const name = rule.text[lang].name
84    const answer = await ask($, rule, e.command, lang)
85
86    if (answer === 'once') {
87      $.ui.toast(`🛡 ${ui.allowedOnce} · ${name}`)
88      return next(e)
89    }
90    if (answer === 'session') {
91      await update($, allowed, l => [...l, rule.id])
92      $.ui.toast(`🛡 ${ui.trusted} · ${name}`)
93      return next(e)
94    }
95    $.ui.toast(`🛡 ${ui.blocked} · ${name}`)
96    // the model reads English either way
97    const { name: en, impact, safer } = rule.text.en
98    if (answer === 'safer') {
99      return { deny: `cmd-guard: the user blocked this command (${en}: ${impact}) and wants the safer alternative: ${safer}. Explain the alternative briefly, then use it.` }
100    }
101    if (answer !== 'block') {
102      return { deny: `cmd-guard: the user blocked this command (${en}) and said: ${answer}` }
103    }
104    return { deny: `cmd-guard: the user blocked this command (${en}: ${impact}). Do not retry it; ask the user how to proceed.` }
105  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'cmd-guard: its check failed, so the command was blocked.' }))
106}
107
hooks/rules.ts 77 lines
1export type Lang = 'en' | 'zh'
2export type RuleText = { name: string; impact: string; safer: string }
3export type Rule = {
4  re: RegExp
5  // stable id: what session allowances remember
6  id: string
7  level: 'CRITICAL' | 'HIGH'
8  text: Record<Lang, RuleText>
9}
10
11// matched against the whole Bash command, so chained commands are caught too.
12// ponytail: regexes, not a shell parser; a guard against slips, not a sandbox
13const BROAD = String.raw`(\/|\/\*|~|~\/|~\/\*|\$HOME|\$HOME\/\*?|\*|\.|\.\.|\.\/\*?)`
14// `git`, then any global options (-C dir, -c key=value, --git-dir=...), then the subcommand
15const GIT = String.raw`\bgit(\s+(-[cC]\s+\S+|--[a-z-]+(=\S+)?))*\s+`
16// the rest of one command: quoted strings whole, stopping at ; & | or newline
17const SEG = String.raw`([^;&|\n'"]|'[^']*'|"[^"]*")*`
18export const RULES: Rule[] = [
19  { re: new RegExp(String.raw`\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|-[a-zA-Z]*f[a-zA-Z]*\s+|--recursive\s+|--force\s+)+${BROAD}(\s|;|&|\||$)`),
20    id: 'rm-broad', level: 'CRITICAL', text: {
21      en: { name: 'Broad rm -rf', impact: 'Recursively deletes the root, home or a wildcard path; cannot be undone', safer: 'Name exact paths, or ls first; use trash to move files to the Trash' },
22      zh: { name: 'rm -rf 大範圍刪除', impact: '遞迴刪除根目錄、家目錄或萬用字元路徑,無法復原', safer: '指定精確路徑,或先 ls 確認再刪;改用 trash 移到垃圾桶' } } },
23  // --force, -f in any flag cluster (-fu), or a +refspec
24  { re: new RegExp(String.raw`${GIT}push\b(?=.*\s(--force(?!-with-lease)\b|-[a-zA-Z]*f[a-zA-Z]*\b|\+\S))`),
25    id: 'git-push-force', level: 'HIGH', text: {
26      en: { name: 'git push --force', impact: 'Overwrites remote history and can erase other people\'s commits', safer: 'git push --force-with-lease' },
27      zh: { name: 'git push --force', impact: '覆寫遠端歷史,可能抹掉他人的 commit', safer: 'git push --force-with-lease' } } },
28  { re: new RegExp(String.raw`${GIT}reset\b${SEG}\s--hard\b`),
29    id: 'git-reset-hard', level: 'HIGH', text: {
30      en: { name: 'git reset --hard', impact: 'Discards every uncommitted change; cannot be undone', safer: 'git stash (keeps the changes; pop them back any time)' },
31      zh: { name: 'git reset --hard', impact: '捨棄所有未提交的變更,無法復原', safer: 'git stash(保留變更,可隨時 pop 回來)' } } },
32  // a dry run (-n, --dry-run) deletes nothing; flags are read only within this
33  // command (SEG), so a later command's -n can't pass for a dry run
34  { re: new RegExp(String.raw`${GIT}clean\b(?!${SEG}\s(-[a-zA-Z]*n[a-zA-Z]*|--dry-run)\b)(?=${SEG}\s(-[a-zA-Z]*f|--force\b))`),
35    id: 'git-clean', level: 'HIGH', text: {
36      en: { name: 'git clean -f', impact: 'Deletes every untracked file', safer: 'git clean -n to preview what would go' },
37      zh: { name: 'git clean -f', impact: '刪除所有未追蹤的檔案', safer: 'git clean -n 先預覽要刪的檔案' } } },
38  { re: /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i,
39    id: 'sql-drop', level: 'CRITICAL', text: {
40      en: { name: 'SQL DROP / TRUNCATE', impact: 'Deletes a whole table or database', safer: 'Back up first (pg_dump / mysqldump), or run it in a transaction' },
41      zh: { name: 'SQL DROP / TRUNCATE', impact: '刪除整張資料表或資料庫', safer: '先備份(pg_dump / mysqldump),或在交易中執行' } } },
42  { re: /\bmkfs(\.\w+)?\b/,
43    id: 'mkfs', level: 'CRITICAL', text: {
44      en: { name: 'mkfs format', impact: 'Formats a disk partition and destroys its data', safer: 'Check the device (diskutil list), then run it yourself' },
45      zh: { name: 'mkfs 格式化', impact: '格式化磁碟分割區,資料全毀', safer: '確認裝置代號(diskutil list)後由你手動執行' } } },
46  // writing to exactly the null / zero / std streams is harmless
47  { re: /\bdd\b.*\bof=\/dev\/(?!(null|zero|stdout|stderr)(\s|;|&|\||$))/,
48    id: 'dd-device', level: 'CRITICAL', text: {
49      en: { name: 'dd to a device', impact: 'Overwrites a disk device directly', safer: 'Check the of= device, then run it yourself' },
50      zh: { name: 'dd 寫入裝置', impact: '直接覆寫磁碟裝置', safer: '確認 of= 的裝置代號後由你手動執行' } } },
51  { re: /\bchmod\s+(-R\s+)?777\s+\/(\s|$)/,
52    id: 'chmod-root', level: 'CRITICAL', text: {
53      en: { name: 'chmod 777 /', impact: 'Makes every file on the system writable by anyone', safer: 'Grant the least permission, only where it is needed' },
54      zh: { name: 'chmod 777 /', impact: '讓整個系統所有檔案都可被任何人寫入', safer: '只對需要的目錄設定最小權限' } } },
55  { re: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
56    id: 'fork-bomb', level: 'CRITICAL', text: {
57      en: { name: 'fork bomb', impact: 'Exhausts system resources and hangs the machine', safer: 'Do not run it' },
58      zh: { name: 'fork bomb', impact: '耗盡系統資源,導致當機', safer: '不要執行' } } },
59  { re: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/,
60    id: 'curl-pipe-sh', level: 'HIGH', text: {
61      en: { name: 'Pipe download to shell', impact: 'Runs a remote script without reading it', safer: 'Download it to a file, read it, then run it' },
62      zh: { name: '下載後直接執行', impact: '未經檢查就執行遠端腳本', safer: '先下載成檔案、讀過內容再執行' } } },
63]
64
65// also read with the quotes gone, the way the shell joins "$HOME"/ or ~/'*' into one word
66export const check = (command: string) => {
67  const unquoted = command.replace(/["']/g, '')
68  return RULES.find(r => r.re.test(command) || r.re.test(unquoted))
69}
70
71// a prompt's language: Chinese when it has at least as many Han characters as Latin words
72export const langOf = (text: string): Lang => {
73  const han = text.match(/\p{Script=Han}/gu)?.length ?? 0
74  const words = text.match(/[A-Za-z]+/g)?.length ?? 0
75  return han > 0 && han >= words ? 'zh' : 'en'
76}
77
types/index.d.ts 10 lines
1export type GuardOff = boolean
2export type GuardAllowed = string[]
3export type GuardPrompts = { en: number; zh: number; last: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'cmd-guard': { off: GuardOff; allowed: GuardAllowed; prompts: GuardPrompts }
8  }
9}
10