SLOPSHOPPER

safety-net

Blocks catastrophic shell commands and asks before risky ones or edits to secret files.

newguard
v0.1.0MITupdated 2026-10-09lakmadev/claude-mods/plugins/safety-net
A shopper browsing a rack in a slop shop
README

claude-mods

Eleven mods for Claude Code. Install the ones you want; each is independent.

Repo Radar

Repo Radar: a live map of where Claude is working

repo-radar draws your repo as a live map of rectangles, one per folder. Reads glow cyan, edits amber, failures red, and each subagent gets its own colour, so you can watch parallel agents work across your codebase. Glows fade like a radar trace; files touched this session keep a faint tint. /radar opens it. More →

/plugin marketplace add lakmadev/claude-mods
/plugin install repo-radar@claude-mods

For fun

pixel-pet: the band

ModWhat it does
pixel-petA tiny animated pixel pet above your prompt. It sleeps (snoring) when things are quiet and wakes slowly when you type. It narrates what Claude is doing with a sliding status line, hatches half-size helpers in their own colours for each subagent that slide into the meter line one by one and wave bye when done. It watches you type, and you click it to pet it. A gradient context meter with your 5-hour and weekly usage sits beside it, details on hover. 28 colours via /pet color <name>.
code-wrapped/wrapped opens your Claude Code Wrapped: a GitHub-style activity heatmap, hours, lines, streaks, top files and tools, your coding personality (Night Owl 🦉, Refactorer 🧹, Shell Wizard 🧙…) and 14 unlockable achievements that pop as toasts. Copy share text puts a one-line brag on your clipboard.
lofi-modeSoft lo-fi beats start when Claude has been working for a few seconds and stop when it finishes. The loop is original and synthesized for this mod. /lofi toggles it. macOS audio.

For work

ModWhat it does
context-meterA plain status line: ctx 62% · 5h 85% resets 1h52m · week 40% on a Claude plan (the reset shows once a window passes 70%), or ctx 62% · $1.84 billed (API) when you pay per token. Toasts when context passes 80% or a plan window passes 90%.
safety-netRefuses catastrophic commands (rm -rf ~, mkfs, dd of=/dev/disk, fork bombs). Asks before risky ones (force-push, reset --hard, `curl \sh, DROP TABLE, terraform destroy, sudo, …) and before edits to .env, keys, ~/.ssh, .git/` or shell rc files.
changes-paneSide pane listing every file Claude changed this session with +/- line counts. /changes opens it.
done-notifierChime, toast and desktop notification when a turn longer than 30s finishes or Claude is waiting for you.
prompt-boosterShort prompts ("fix it") get your branch, changed files and recent commits as hidden context. Adds /fix-tests, /explain-code, /review-changes, /write-tests, /commit-msg.
session-journalLogs each turn (project, prompt, files touched) across sessions. /journal [days] lists it; /standup [days] writes a Done / In progress / Blockers update.
secrets-redactorReplaces API keys, tokens, private keys and connection-string passwords with [REDACTED:kind] in your prompts and in tool output before the model sees them, and blocks writing a placeholder back over a real secret.

Install

Requires Claude Code 2.1.293 or later: mods are an early-access feature and gain events with each release. Check with claude --version; update with claude update.

In a Claude Code terminal session:

/plugin marketplace add lakmadev/claude-mods
/plugin install pixel-pet@claude-mods

The first line adds this repo as a plugin marketplace (once); the second installs a mod from it. Repeat the second line with any other mod name from the table.

Settings

Each mod's options show in /config once it is installed:

  • context-meter: contextWarnPercent (80), rateLimitWarnPercent (90)
  • safety-net: strict (off). Turns every "ask" into a refusal. Turn it on if you run in auto or bypass-permissions mode, where an ask can be approved without you.
  • changes-pane: autoOpen (on). The pane opens on its own only in terminals 144+ columns wide.
  • done-notifier: minSeconds (30), sound (on), desktop (on)
  • prompt-booster: autoContext (on), maxWords (15)
  • session-journal: retentionDays (30), model (haiku)
  • secrets-redactor: aggressive (off). Also redacts quoted password = "…"-style assignments.
  • pixel-pet: name (Bit), color (teal)
  • lofi-mode: volume (0.35), delaySeconds (4)

Limits

  • safety-net matches patterns. It is not a sandbox: obfuscated commands (eval, base64, aliases) get through.
  • secrets-redactor only catches known token shapes. Once a secret is redacted, Claude can't use it: put real values in files yourself.
  • pixel-pet, code-wrapped and session-journal keep their data in Claude Code's plugin store on your machine. Two sessions finishing a turn at the same instant can drop one update.
  • done-notifier plays its chime on macOS only. Desktop notifications use osascript (macOS) or notify-send (Linux).
  • session-journal keeps your prompts locally, in Claude Code's plugin store on your machine. /standup sends the log to the model you configure.

Develop

Each mod lives in plugins/<name>/: hooks/register.ts(x) is the code and tests/ holds its tests.

claude plugin validate plugins/<name>
claude plugin test plugins/<name>
claude --plugin-dir plugins/<name>

License

MIT, see LICENSE.

Source 1 files
hooks/register.ts 87 lines
1import type { Register } from 'claude-code'
2
3export type Verdict = { level: 'deny' | 'ask'; why: string }
4
5type Rule = [level: Verdict['level'], pattern: RegExp, why: string]
6
7// ponytail: regex screening, not a shell parser; obfuscated commands (eval, base64, aliases) get through. It's a net, not a sandbox.
8const BASH_RULES: Rule[] = [
9  ['deny', /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, 'fork bomb'],
10  ['deny', /\bmkfs(\.\w+)?\s/, 'formats a filesystem'],
11  ['deny', /\bdd\b[^;&|]*\bof=\/dev\/(r?disk|sd|nvme|hd|mmcblk)/, 'writes raw bytes over a disk'],
12  ['deny', />\s*\/dev\/(r?disk|sd|nvme|hd)\w*/, 'overwrites a disk device'],
13  ['deny', /\bchmod\s+(-\S+\s+)*-\w*R\w*\s+(0?777|a\+rwx)\s+\/(\s|$)/, 'makes the whole filesystem world-writable'],
14  ['ask', /\bgit\s+push\b[^;&|]*\s(--force(?!-with-lease)|-f)\b/, 'force-push rewrites remote history'],
15  ['ask', /\bgit\s+reset\s+[^;&|]*--hard\b/, 'git reset --hard discards uncommitted work'],
16  ['ask', /\bgit\s+clean\s+[^;&|]*-\w*f/, 'git clean deletes untracked files'],
17  ['ask', /\bgit\s+(checkout\s+--\s+\.|restore\s+(-\S+\s+)*\.)(\s|$)/, 'discards all uncommitted changes'],
18  ['ask', /\bgit\s+(branch\s+[^;&|]*-D\b|stash\s+(drop|clear)\b)/, 'deletes branches or stashes for good'],
19  ['ask', /\b(curl|wget)\b[^;&]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/, 'pipes a downloaded script straight into a shell'],
20  ['ask', /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i, 'drops or truncates database objects'],
21  ['ask', /\bdelete\s+from\s+[\w."`]+\s*(;|"|'|$)/i, 'DELETE without a WHERE clause'],
22  ['ask', /\bterraform\s+(destroy\b|apply\b[^;&|]*-auto-approve)/, 'changes infrastructure without a plan review'],
23  ['ask', /\b(kubectl\s+delete|helm\s+uninstall|docker\s+(system|volume)\s+prune|gh\s+repo\s+delete)\b/, 'deletes cluster, container or repository resources'],
24  ['ask', /\baws\s+s3\s+(rm|rb)\b[^;&|]*--(recursive|force)/, 'bulk-deletes S3 data'],
25  ['ask', /\b(npm|pnpm|yarn)\s+publish\b|\bcargo\s+publish\b|\btwine\s+upload\b|\bgem\s+push\b/, 'publishes a package publicly'],
26  ['ask', /(^|[;&|(]\s*)sudo\s/, 'runs as root'],
27]
28
29const ROOTISH = /^["']?(\/|\/\*|~|~\/|~\/\*|\$\{?HOME\}?\/?\*?|\*|\.|\.\/|\.\/\*|\.\.|\.\.\/|\/(usr|etc|bin|sbin|var|opt|home|Users|System|Library|Applications)\/?)["']?$/
30
31// rm needs real argument handling: `rm -rf node_modules` is routine, `rm -rf ~` is not.
32function judgeRm(command: string): Verdict | undefined {
33  for (const segment of command.split(/[;&|\n]+/)) {
34    const words = segment.trim().split(/\s+/)
35    if (words[0] === 'sudo') words.shift()
36    if (words[0] !== 'rm') continue
37    const flags = words.filter(w => w.startsWith('-'))
38    const targets = words.slice(1).filter(w => !w.startsWith('-'))
39    const isRecursive = flags.some(f => f === '--recursive' || /^-[a-zA-Z]*[rR]/.test(f))
40    if (!isRecursive) continue
41    if (targets.some(t => ROOTISH.test(t))) return { level: 'deny', why: 'recursive delete of a root, home or whole-project directory' }
42    if (targets.some(t => /^["']?\$/.test(t))) return { level: 'ask', why: 'recursive delete of a path built from a variable (empty variable = wrong directory)' }
43  }
44  return undefined
45}
46
47export function judgeBash(command: string): Verdict | undefined {
48  const rm = judgeRm(command)
49  if (rm) return rm
50  const hit = BASH_RULES.find(([, pattern]) => pattern.test(command))
51  return hit && { level: hit[0], why: hit[2] }
52}
53
54const PATH_RULES: [RegExp, string][] = [
55  [/(^|\/)\.env(\.(?!example$|sample$|template$|dist$)[\w.-]+)?$/, 'an environment/secrets file'],
56  [/\.(pem|key|p12|pfx|keystore|jks)$/, 'a key or certificate file'],
57  [/(^|\/)(id_(rsa|ed25519|ecdsa|dsa)(\.pub)?|\.ssh\/|\.gnupg\/|\.aws\/credentials|\.netrc|\.npmrc|\.pypirc)/, 'a credentials file'],
58  [/(^|\/)\.git\//, "git's internal database"],
59  [/(^|\/)\.(zshrc|bashrc|bash_profile|profile|zprofile)$/, 'your shell startup file'],
60]
61
62export function judgePath(path: string): Verdict | undefined {
63  const hit = PATH_RULES.find(([pattern]) => pattern.test(path))
64  return hit && { level: 'ask', why: `edits ${hit[1]} (${path})` }
65}
66
67export function judge(tool: string, input: unknown): Verdict | undefined {
68  const args = (input ?? {}) as Record<string, unknown>
69  if (tool === 'Bash') return judgeBash(String(args.command ?? ''))
70  const path = args.file_path ?? args.notebook_path
71  if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].includes(tool) && typeof path === 'string') return judgePath(path)
72  return undefined
73}
74
75export const register: Register = (on, options) => {
76  on('tool.check', async ($, e, next) => {
77    const verdict = judge(String(e.tool), e.input)
78    if (!verdict) return next(e)
79    const reason = `safety-net: ${verdict.why}.`
80    if (verdict.level === 'deny' || options.strict === true) {
81      return { decision: 'deny', reason: `${reason} Blocked; if this is really intended, ask the user to run it themselves.` }
82    }
83    const below = await next(e)
84    return below.decision === 'deny' ? below : { decision: 'ask', reason }
85  }).catch(() => ({ decision: 'ask', reason: 'safety-net could not check this call; please review it.' }))
86}
87