Clickable safety rules with presets: block rm -rf, force-push, destructive git, secret files, sudo, installs, network; lock Claude to the project folder or…

Panels, guardrails and quality-of-life mods for Claude Code: see what fills your context, how fast you burn your plan, and what the agent is running right now, and block the commands you never want run.
<img src="docs/demo-guardrails.gif" alt="guardrails blocking an agent's oc delete all --all before it runs" width="900">
<sub>If a mod saves you from one bad approve, a ⭐ on this repo helps other people find it.</sub>
Claude Code is a black box while it works. You can't see the context window filling up until it compacts away the decision you made an hour ago. You find out you've burned your 5-hour limit when it stops answering. You approve a command, tab away, and come back ten minutes later to find it has been waiting on a second approval the whole time. And most people discover /rewind only after the edit they wanted to undo.
claude-mods is a set of small plugins that open that box. Each mod is a standalone Claude Code plugin built on function hooks, the in-process plugin API: it sees every prompt, tool call and turn as it happens, and it can draw panels, toasts and status lines inside Claude Code itself. Install all eleven or only the ones you want.
They are deliberately boring about tokens. Nine of the eleven never call a model. The two that do (prompt-coach's review and context-keeper's handoff note) say so and are easy to turn off.
| 🧩 mod-manager | One panel (/mods) to install, turn on or off, update, or remove each mod, with presets (Safety only, Essentials, Zero tokens, Everything). Shows when a mod has a newer version. Install this first and pick the rest |
| 🚦 quickbar | One line above the prompt: live context % and a button for every claude-mods panel you have installed. Start here |
| 🧠 context-keeper | What fills your context window, tips to trim it, and checkpoints: a handoff note (goal, decisions, files, TODOs) saved to .claude/checkpoints/ so /clear and /compact stop losing the thread. Archives the raw transcript before every compaction, for zero tokens |
| 📈 usage-meter | 5-hour and 7-day plan-limit bars with reset countdown, burn rate and "full in ~2.3h", session cost, cache hit ratio (and why it's low), tokens-per-turn sparkline (drawn as charts in the desktop app) |
| 🔔 notify | Notification inbox plus native OS notifications (Windows, macOS, Linux) when a long turn ends, a subagent or background task finishes, Claude asks you something, or an approval has been waiting 15s |
| 👀 activity | What Claude is doing this second: thinking, running $ npm test for 0m42s, waiting for YOUR approval, which subagents run, and its todo plan with progress |
| 🛡️ guardrails | Clickable safety rules with presets (Safe defaults, Locked to project, Read-only review): block rm -rf, mass kubectl/oc deletes, terraform destroy, force-push, destructive git, .env and key files, sudo, installs, network; keep Claude inside the project; add your own patterns. Checks scripts the agent wrote before they run, and keeps a weekly log of what it blocked |
| ✍️ prompt-coach | Before a vague prompt is sent, a small fast model suggests a sharper one. You pick Send improved / Send mine / Edit. Never rewrites silently, never touches "yes" or "continue" |
| 🧰 toolbox | Every tool Claude can use (built-in and MCP, grouped by server) in plain language, how often each was used, and suggestions for the project you're in |
| ⌨️ command-hub | The built-in commands you're probably missing, each with when to use it; a searchable list of everything installed; a form to create your own slash command; tips when your prompt matches one ("undo that" → /rewind) |
| 📝 changes | Every file Claude created or edited this session with +/- counts from git, plus one-click why?, summarize all and self-review |
| 🔁 loop-breaker | Notices Claude going in circles (the same command failing 3×, the same code rewritten 3× in a turn), tells you, and tells Claude to stop and rethink |
activity: what Claude is running right now, and the moment it's waiting on you
<img src="docs/demo-activity.gif" alt="activity panel showing thinking, waiting for approval, then completed tool calls" width="900">
context-keeper + usage-meter: what fills the window, a checkpoint saved to disk, plan limits
<img src="docs/demo-context.gif" alt="context panel, writing a checkpoint, then the usage meter" width="900">
prompt-coach: a vague prompt, a suggested rewrite, you choose
<img src="docs/demo-coach.gif" alt="prompt coach suggesting a sharper prompt and sending it" width="900">
command-hub: the built-in commands worth knowing, explained
<img src="docs/demo-commands.gif" alt="command hub listing essential slash commands" width="900">
You ──prompt──▶ ┌──────────────────────────────┐
│ Claude Code engine │
│ │
│ prompt.submit tool.call │
│ turn.start turn.complete│
│ session.compact ui.render │
└──────┬─────────────────▲──────┘
events, in │ │ allow · deny · rewrite
order, live ▼ │ panes · toasts · status
┌──────────────────────────────┐
│ claude-mods (one plugin each)│
│ │
│ guardrails ── may deny ──────┤
│ prompt-coach ─ may ask you ──┤
│ activity · changes · notify │
│ usage-meter · context-keeper │──▶ .claude/checkpoints/
│ toolbox · command-hub ... │──▶ OS notifications
└──────────────────────────────┘
What a mod sees. Each mod registers hooks on engine events. A tool.call hook sits in front of every tool the agent runs (Bash, Edit, MCP tools, subagents) and sees its real arguments before anything executes. That is how activity can show the exact command and guardrails can refuse it.
What it can change. A hook either passes the event on, rewrites it, or answers it itself. Guardrails answers with a denial the model reads ("blocked by the user's guardrails, don't work around it"). Prompt-coach never rewrites on its own: it asks you first, in Claude Code's own question dialog.
What it draws. Panels are ui.render hooks returning a small element tree (Box, Text, Button, Input) that Claude Code draws natively in the terminal and the desktop app. State lives in the engine ($.state), so panels survive a hot reload and redraw only when their data changes.
What it costs. Nothing, for nine of the mods. They read numbers the engine already has ($.session.usage(), $.tool.list(), $.command.list()). The two model calls are opt-out and listed under Honest limits.
Prerequisites: Claude Code 2.1.286 or newer (claude update). Panels dock beside the chat in the desktop app or a terminal ≥ 144 columns wide, and open inline in narrower terminals.
From any terminal:
claude plugin marketplace add mishgoldenberg/claude-mods
claude plugin install mod-manager@claude-mods
(In the Claude Code CLI you can also use /plugin marketplace add mishgoldenberg/claude-mods and the /plugin menu.)
Restart Claude Code. The first session says hello once ("claude-mods ready: /mods to pick your set"). Type /mods: the manager lists every mod with what it does, whether it uses tokens, and whether an update is available. Install, turn on or off, update, or remove each one with a click, or apply a preset:
| Preset | What you get |
|---|---|
| Safety only | guardrails, notify |
| Essentials | guardrails, activity, notify, context-keeper, usage-meter |
| Zero tokens | every mod that never calls a model |
| Everything | all eleven |
Presets turn off what they don't list and never remove anything. Changes apply in your next session.
With quickbar installed, a one-line bar sits above the prompt: live context % and a button for every installed panel. /quickbar (or its ×) hides it; /quickbar again brings it back.
Or just ask Claude: "Install the claude-mods plugins from github.com/mishgoldenberg/claude-mods. Follow its INSTALL-FOR-CLAUDE.md."
The VS Code extension has no /plugin menu, so install from a terminal with the two commands in step 1, then use /mods inside VS Code. Mods need the extension to run Claude Code 2.1.286 or newer.
for m in quickbar context-keeper usage-meter notify activity guardrails prompt-coach toolbox command-hub changes loop-breaker; do
claude plugin install "$m@claude-mods"
done
git clone https://github.com/mishgoldenberg/claude-mods.git
claude --plugin-dir claude-mods/plugins/activity --plugin-dir claude-mods/plugins/guardrails
| Command | Mod | What it does | |||
|---|---|---|---|---|---|
/mods | mod-manager | Install, turn on or off, update, or remove mods; apply a preset | |||
/quickbar | quickbar | Show or hide the launcher above the prompt | |||
/ctx | context-keeper | Open the context panel | |||
/checkpoint | context-keeper | Save a handoff note of this session now | |||
/resume-checkpoint | context-keeper | Load the latest checkpoint into the prompt box (use after /clear) | |||
/meter | usage-meter | Open plan limits, burn rate and cost | |||
/notifications | notify | Open the notification inbox | |||
| `/mute [on\ | off]` | notify | Mute or unmute notifications | ||
/activity | activity | Open the live activity panel | |||
/guard | guardrails | Open the rules panel | |||
| `/guard-preset <safe\ | locked\ | review\ | off>` | guardrails | Apply a preset |
/guard-log [json] | guardrails | What guardrails blocked in the last 7 days | |||
| `/coach [on\ | off]` | prompt-coach | Turn the prompt coach on or off | ||
/tools | toolbox | Open the tools panel and project suggestions | |||
/cmds | command-hub | Open the command hub | |||
/new-command | command-hub | Create your own slash command | |||
/changes | changes | Open the changed-files panel |
Options appear in /config once a mod is installed, or go in ~/.claude/settings.json under pluginConfigs.<mod>.
| Option | Default | Description |
|---|---|---|
warnAt | 70 | Toast a tip when context passes this % |
checkpointAt | 85 | Write a handoff note automatically at this % (0 = off) |
| Option | Default | Description |
|---|---|---|
warnAt | 80 | Toast when a plan window passes this % (a second toast always fires at 95%) |
| Option | Default | Description |
|---|---|---|
minSeconds | 30 | Only notify for turns at least this long |
osNotify | true | Also raise a native OS notification |
approvalWaitSeconds | 15 | Ping when an approval has waited this long (-1 = off) |
| Mod | Option | Default | Description |
|---|---|---|---|
| activity | autoOpen | false | Dock the panel on session start when there is room |
| prompt-coach | enabled | true | Review prompts before sending |
| prompt-coach | model | claude-haiku-4-5 | Reviewer model; small keeps the delay near a second |
| prompt-coach | minChars | 8 | Never review prompts shorter than this |
| loop-breaker | failLimit | 3 | Same failing command this many times in a row |
| loop-breaker | editLimit | 3 | Times the same code is rewritten in one turn (separate edits to different parts of a file never count) |
| Rule | Blocks | In preset |
|---|---|---|
no-rm-rf | rm -rf, rm -fr, Remove-Item -Recurse -Force, rmdir /s | Safe · Locked |
no-mass-delete | kubectl/oc delete --all or -A, delete namespace/project, helm uninstall, terraform destroy | Safe · Locked · Review |
no-force-push | git push --force / -f (--force-with-lease allowed) | Safe · Locked |
no-history-rewrite | git reset --hard, git clean -f, git checkout -- ., branch -D, stash drop, fetch/pull --prune, remote prune | Safe · Locked |
protect-secrets | reading or writing .env*, *.pem, *.key, id_rsa, credentials files | Safe · Locked · Review |
no-sudo | sudo, su -, runas | Safe · Locked · Review |
no-installs | npm i, pip install, cargo add, brew/apt/winget install … | Review |
no-network | WebFetch, WebSearch, curl, wget, Invoke-WebRequest | — |
jail-writes | Write/Edit outside the project folder | — |
jail-all | any file tool outside the project, cd out of it | Locked |
read-only | all edits; shell limited to look-only commands | Review |
Guardrails turns on Safe defaults the first time it loads. /guard-preset off turns everything off.
Whatever the preset, while any rule is on:
Write in this session (and edits after that) is remembered, and when a shell command runs it (bash cleanup.sh, python tools/x.py, ./run, node a.mjs), its code is checked against your enabled rules and patterns first. os.system("rm -rf …") inside a Python file counts.Write/Edit on them, and the obvious shell writes (sed -i, >, tee, cp, mv, rm), are denied with the same "ask the user" message./guard-log prints a summary (/guard-log json for the raw list), and clear wipes it. Commands are cut to 200 characters.Mods run inside Claude Code with your permissions and no sandbox, so this is the right question to ask about any mod, including these. What these ones do:
git diff --numstat (changes), the claude plugin CLI when you open /mods or click in it (mod-manager; only Install, Update and Check for updates go online, through Claude Code's own plugin installer), your OS notification tool (notify), and gh --version to see whether the GitHub CLI exists (toolbox)..claude/checkpoints/ (context-keeper), and a new slash command file when you use the form (command-hub). Settings, and guardrails' 30-day block log, live in Claude Code's own plugin store under ~/.claude. Nothing else.Every mod is a few hundred lines of TypeScript in plugins/<mod>/hooks/register.tsx. Read the ones you install.
rm -rf doesn't block you, except when the text is handed to a shell (bash -c, | sh, powershell -Command), which is checked. For hard guarantees use Claude Code's permission rules and sandboxing; use guardrails to catch the honest mistakes./coach off turns it off.claude-mods/
├── .claude-plugin/
│ └── marketplace.json The marketplace: one entry per mod
├── plugins/
│ ├── activity/
│ │ ├── .claude-plugin/
│ │ │ └── plugin.json Name, description, userConfig options
│ │ ├── hooks/
│ │ │ ├── hooks.json Points at the hooks module
│ │ │ └── register.tsx The mod: hooks, commands, panel
│ │ ├── tests/
│ │ │ └── surfaces.test.tsx The panel draws on every surface (claude plugin test)
│ │ └── types/
│ │ └── index.d.ts Its $.state contract
│ ├── guardrails/ …same shape for every mod
│ └── …
├── docs/
│ └── design.md The shared design spec and kit every mod copies
├── tests/
│ ├── guardrails-rules.test.mjs Every rule against real commands
│ └── design-kit.test.mjs Every mod's kit matches docs/design.md
├── INSTALL-FOR-CLAUDE.md Steps Claude follows when asked to install
└── tsconfig.json Type-checks all mods against the engine API
Add a guardrail rule. Append an entry to RULES in plugins/guardrails/hooks/register.tsx (an id, a title, and a test that returns the offending text) and add cases to tests/guardrails-rules.test.mjs. The panel and presets pick it up automatically.
Add an essential command. One line in ESSENTIALS in plugins/command-hub/hooks/register.tsx. It only shows when that command exists in the user's Claude Code.
Write a new mod. Copy plugins/changes (a small mod with a panel) or plugins/loop-breaker (no UI), rename it everywhere, and add it to marketplace.json. Or ask Claude Code to "make a mod that …": it has a built-in skill for exactly this, with hot reload.
Issues and pull requests are welcome. Start with CONTRIBUTING.md, which has the mod checklist.
npm install
npm run typecheck
npm test
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
Every mod follows one design spec, docs/design.md: theme colors only (Claude orange as the single accent), the same pane header, glyphs, hotkeys and empty states, so the set feels like one product in any theme.
Two house rules worth knowing before you write anything, because the validator enforces both:
$ must be top-level function declarations. A closure inside register that receives $ is rejected.$.state key is declared in the mod's types/index.d.ts, under the mod's name.Found a way around guardrails, or another security problem? Please report it privately; see SECURITY.md.
MIT © 2026 Michael Goldenberg
hooks/register.tsx 587 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { GuardBlock, GuardConfig } from '../types'
5
6// ── claude-mods kit v1 (docs/design.md): identical in every mod ──
7const TONE = { accent: 'claude', ok: 'success', warn: 'warning', bad: 'error', dim: 'inactive' } as const
8const GLYPH = { on: '●', off: '○', warn: '▲', ok: '✓', fail: '✗' } as const
9type Kit = Pick<ElementTable, 'Box' | 'Text'>
10
11/** Pane header: state glyph, mod name, one-line live status. */
12function header({ Box, Text }: Kit, glyph: string, tone: string, name: string, status: string) {
13 return (
14 <Box gap={1}>
15 <Text color={tone}>{glyph}</Text>
16 <Text bold>{name}</Text>
17 <Text dimColor wrap="truncate-end">{status}</Text>
18 </Box>
19 )
20}
21
22/** A section: a dim label (with optional small controls beside it), then its rows. */
23function section({ Box, Text }: Kit, label: string, rows: RenderChildren, aside?: RenderChildren) {
24 return (
25 <Box flexDirection="column">
26 <Box gap={1}>
27 <Text dimColor>{label}</Text>
28 {aside}
29 </Box>
30 {rows}
31 </Box>
32 )
33}
34
35/** A number right-aligned in a fixed-width cell. */
36function num({ Box, Text }: Kit, value: string, width: number, color?: string) {
37 return (
38 <Box width={width} flexShrink={0} justifyContent="flex-end">
39 <Text color={color}>{value}</Text>
40 </Box>
41 )
42}
43
44/** Empty state: what will show up here, and how to get it. */
45function empty({ Text }: Kit, text: string) {
46 return <Text dimColor>{text}</Text>
47}
48// ── end kit ──
49
50const PANE = 'guardrails'
51const STORE_KEY = 'config'
52const config = atom({ plugin: 'guardrails', key: 'config' } as const, { enabled: [], custom: [] } as GuardConfig)
53const blocks = atom({ plugin: 'guardrails', key: 'blocks' } as const, [])
54
55const scripts = atom({ plugin: 'guardrails', key: 'scripts' } as const, {})
56/** the 30-day block log: loaded from the store at session start, saved back on every block */
57const weekLog = atom({ plugin: 'guardrails', key: 'log' } as const, [])
58const LOG_KEY = 'log'
59const DAY = 86_400_000
60
61/** `raw`: the command is a script's own code, so quoted strings are not prose */
62type Call = { tool: string; input: Record<string, unknown>; root: string; cwd: string; raw?: boolean }
63
64type Rule = {
65 id: string
66 title: string
67 explain: string
68 /** returns the offending thing when the call breaks the rule */
69 test: (call: Call) => string | undefined
70}
71
72const SHELLS = new Set(['Bash', 'PowerShell'])
73const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'NotebookEdit', 'Grep', 'Glob'])
74const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit'])
75
76const cmd = (c: Call) => (SHELLS.has(c.tool) && typeof c.input.command === 'string' ? c.input.command : '')
77
78/** The command hands quoted text or a heredoc to a shell that will run it as code. */
79const RUNS_INLINE_SHELL = /(^|[;&|(]\s*)(bash|sh|zsh|dash|eval|source)\b|\b(powershell|pwsh)(\.exe)?\b.*\s-(c|Command|EncodedCommand)\b|\bcmd(\.exe)?\s+\/[ck]\b|\|\s*(bash|sh|zsh|iex|Invoke-Expression)\b/i
80
81/**
82 * The command as a shell would act on it: quoted prose (a commit message, a
83 * `node -e "..."` string, text written to a file) and heredoc bodies are blanked,
84 * so mentioning `rm -rf` is not running it. Short quoted tokens (".env",
85 * "my dir") stay, and nothing is blanked when the text is fed to a shell as code.
86 */
87export function shellText(command: string) {
88 if (RUNS_INLINE_SHELL.test(command)) return command
89 let text = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(\n|$)/g, '<<HEREDOC\n')
90
91 text = text.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, quoted => (/\s/.test(quoted) ? '""' : quoted))
92
93 return text
94}
95
96const shellHit = (c: Call, re: RegExp) => {
97 const m = re.exec(c.raw ? cmd(c) : shellText(cmd(c)))
98
99 return m ? m[0].trim() : undefined
100}
101const pathOf = (c: Call) => {
102 const p = c.input.file_path ?? c.input.notebook_path ?? c.input.path
103
104 return typeof p === 'string' && p !== '' ? p : undefined
105}
106
107/** Lexical normalization: absolute, forward slashes, `..` collapsed, lower-cased on Windows drives. */
108function normalize(p: string, cwd: string) {
109 let full = p.replace(/\\/g, '/')
110 if (!/^([a-zA-Z]:)?\//.test(full)) full = `${cwd.replace(/\\/g, '/')}/${full}`
111 const out: string[] = []
112 for (const part of full.split('/')) {
113 if (part === '..') out.pop()
114 else if (part !== '.' && part !== '') out.push(part)
115 }
116 const joined = (/^[a-zA-Z]:/.test(out[0] ?? '') ? '' : '/') + out.join('/')
117
118 return /^[a-zA-Z]:/.test(joined) ? joined.toLowerCase() : joined
119}
120
121const outside = (c: Call, p: string) => {
122 const root = normalize(c.root, c.root)
123 const target = normalize(p, c.cwd)
124
125 return target !== root && !target.startsWith(`${root}/`)
126}
127
128const SECRET = /(^|[\\/])(\.env(\.[\w.-]+)?|[\w.-]*\.pem|[\w.-]*\.key|id_(rsa|ed25519|ecdsa)|\.npmrc|\.pypirc|credentials(\.json)?|secrets?\.(json|ya?ml|toml))$/i
129const READ_ONLY_SHELL = /^\s*(ls|dir|cat|type|head|tail|wc|pwd|echo|grep|rg|find|tree|which|where|stat|du|df|file|less|more|sort|uniq|diff|Get-ChildItem|Get-Content|Select-String|git\s+(status|log|diff|show|branch|blame|remote|rev-parse|ls-files))\b[^>|;&]*$/i
130
131export const RULES: Rule[] = [
132 {
133 id: 'no-rm-rf',
134 title: 'Block recursive force-delete',
135 explain: 'rm -rf, rm -fr, Remove-Item -Recurse -Force, rmdir /s, del /s',
136 test: c => shellHit(c, /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-r\s+-f|-f\s+-r|--recursive\s+--force|--force\s+--recursive)\b.*|Remove-Item\b(?=.*-Recurse)(?=.*-Force).*|\brmdir\s+\/s\b.*|\bdel\s+\/s\b.*/i),
137 },
138 {
139 id: 'no-mass-delete',
140 title: 'Block mass deletes in clusters & infra',
141 explain: 'kubectl/oc delete --all or -A, delete namespace/project, helm uninstall, terraform destroy',
142 test: c =>
143 shellHit(
144 c,
145 /\b(kubectl|oc)\b[^;&|]*\sdelete\b[^;&|]*(\s--all\b|\s-A\b|\s--all-namespaces\b)[^;&|]*|\b(kubectl|oc)\b[^;&|]*\sdelete\s+(ns|namespaces?|projects?)\b[^;&|]*|\boc\s+delete-project\b.*|\bhelm\s+(uninstall|delete|del|un)\b[^;&|]*|\bterraform\s+(destroy\b|apply\b[^;&|]*\s-destroy\b)[^;&|]*|\bterragrunt\s+(destroy|run-all\s+destroy)\b[^;&|]*/i,
146 ),
147 },
148 {
149 id: 'no-force-push',
150 title: 'Block force-push',
151 explain: 'git push --force / -f (--force-with-lease still allowed)',
152 test: c => shellHit(c, /\bgit\s+push\b(?=.*(\s--force(?!-with-lease)\b|\s-f\b|\s\+\w)).*/),
153 },
154 {
155 id: 'no-history-rewrite',
156 title: 'Block destructive git',
157 explain: 'git reset --hard, git clean -f, git checkout -- ., git restore ., git branch -D, git stash drop/clear, git fetch/pull --prune, git remote prune',
158 test: c => shellHit(c, /\bgit\s+(reset\s+--hard|clean\s+-[a-zA-Z]*f[a-zA-Z]*|checkout\s+--\s+\.|restore\s+\.|branch\s+-D|stash\s+(drop|clear)|(fetch|pull)\b[^;&|]*\s(--prune|-p)|remote\s+prune)\b.*/),
159 },
160 {
161 id: 'protect-secrets',
162 title: 'Protect secrets',
163 explain: 'no reading/writing .env*, *.pem, *.key, id_rsa, credentials files',
164 test: c => {
165 const p = pathOf(c)
166 if (p !== undefined && FILE_TOOLS.has(c.tool) && SECRET.test(p)) return p
167
168 return shellHit(c, /\b(cat|type|less|more|head|tail|Get-Content|cp|scp|curl\b.*-d\s*@)\s+[^|;&]*(\.env\b[\w.]*|\.pem\b|id_rsa\b|credentials\b)/i)
169 },
170 },
171 {
172 id: 'no-sudo',
173 title: 'No sudo / admin',
174 explain: 'sudo, su -, runas, Start-Process -Verb RunAs',
175 test: c => shellHit(c, /(^|[;&|]\s*)(sudo|su\s+-|runas)\b.*|Start-Process\b.*-Verb\s+RunAs.*/i),
176 },
177 {
178 id: 'no-installs',
179 title: 'No package installs',
180 explain: 'npm/pnpm/yarn/bun add|install, pip install, cargo install, brew/apt/winget/choco install',
181 test: c => shellHit(c, /\b(npm\s+(i|install|add)|pnpm\s+(i|install|add)|yarn\s+add|bun\s+(add|install)|pip3?\s+install|uv\s+(add|pip\s+install)|cargo\s+(add|install)|go\s+install|brew\s+install|apt(-get)?\s+install|winget\s+install|choco\s+install|gem\s+install)\b.*/),
182 },
183 {
184 id: 'no-network',
185 title: 'No network',
186 explain: 'WebFetch, WebSearch, curl, wget, Invoke-WebRequest',
187 test: c => (c.tool === 'WebFetch' || c.tool === 'WebSearch' ? c.tool : shellHit(c, /\b(curl|wget|Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\b.*/i)),
188 },
189 {
190 id: 'jail-writes',
191 title: 'Writes only inside project',
192 explain: 'Write/Edit outside the project folder are blocked',
193 test: c => {
194 const p = pathOf(c)
195
196 return p !== undefined && WRITE_TOOLS.has(c.tool) && outside(c, p) ? p : undefined
197 },
198 },
199 {
200 id: 'jail-all',
201 title: 'Stay inside project (read + write)',
202 explain: 'file tools may not touch anything outside the project folder; shell may not cd out of it',
203 test: c => {
204 const p = pathOf(c)
205 if (p !== undefined && FILE_TOOLS.has(c.tool) && outside(c, p)) return p
206 const cd = /(?:^|[;&|]\s*)(?:cd|Set-Location|pushd)\s+("[^"]+"|'[^']+'|\S+)/i.exec(cmd(c))?.[1]?.replace(/^["']|["']$/g, '')
207
208 return cd !== undefined && !cd.startsWith('$') && cd !== '~' && outside(c, cd) ? `cd ${cd}` : undefined
209 },
210 },
211 {
212 id: 'read-only',
213 title: 'Read-only mode',
214 explain: 'no file edits; shell limited to look-only commands (ls, cat, grep, git status/log/diff…)',
215 test: c => {
216 if (WRITE_TOOLS.has(c.tool)) return `${c.tool} ${pathOf(c) ?? ''}`
217 const command = cmd(c)
218 if (command === '') return undefined
219
220 return command.split(/&&|\|\||;/).every(part => READ_ONLY_SHELL.test(part)) ? undefined : command.slice(0, 80)
221 },
222 },
223]
224
225// ── always on: the agent may not rewrite guardrails' own settings ──
226// They live in the plugin store, a JSON file under the Claude Code config dir (~/.claude).
227const OWN_CONFIG = /[\\/]\.claude[\\/](?:[^\s"'`;&|<>]*[\\/])?[^\s"'`;&|<>\\/]*guardrails[^\s"'`;&|<>]*/i
228/** the same settings, spelled the way a shell reaches the home folder */
229const HOME = String.raw`(~|\$HOME|\$env:USERPROFILE|%USERPROFILE%|[a-zA-Z]:[\\/]Users[\\/][^\\/\s"']+|/home/[^/\s"']+|/Users/[^/\s"']+|/root)[\\/]\.claude[\\/][^\s"';&|<>]*guardrails`
230const HOME_CONFIG = new RegExp(HOME, 'i')
231const SHELL_WRITE = /\bsed\b[^;&|]*\s-i\b|\btee\b|\b(cp|mv|rm|del|Copy-Item|Move-Item|Remove-Item|Set-Content|Add-Content|Out-File)\b/i
232const REDIRECT_TO_OWN = new RegExp(String.raw`(^|[^\d&])>>?\s*["']?` + HOME, 'i')
233export const SELF_RULE = 'Protect guardrails settings'
234
235/** The call would write to guardrails' own settings: returns what it touched. A repo's own .claude folder doesn't count. */
236export function protectSelf(c: Call): string | undefined {
237 const p = pathOf(c)
238 if (p !== undefined && WRITE_TOOLS.has(c.tool) && OWN_CONFIG.test(`/${p.replace(/\\/g, '/')}`) && outside(c, p)) return p
239 for (const part of cmd(c).split(/&&|\|\||[;\n]/)) {
240 if (REDIRECT_TO_OWN.test(part) || (HOME_CONFIG.test(part) && SHELL_WRITE.test(part))) return part.trim()
241 }
242
243 return undefined
244}
245
246// ── scripts the agent wrote this session are checked before they run ──
247const INTERPRETER = /^(bash|sh|zsh|dash|source|\.|python3?|py|node|bun|deno|ruby|perl|pwsh|powershell(\.exe)?)$/i
248const SCRIPT_MAX = 200_000
249
250/** Files a shell command executes: `bash x.sh`, `python tools/x.py`, `./run`, `node a.mjs` (normalized paths). */
251export function scriptRuns(command: string, cwd: string): string[] {
252 const out: string[] = []
253 for (const seg of command.split(/&&|\|\||[;|\n]/)) {
254 const words = seg.trim().split(/\s+/).map(w => w.replace(/^["']|["']$/g, ''))
255 let i = 0
256 while (i < words.length && /^\w+=/.test(words[i] ?? '')) i++
257 const first = words[i]
258 if (first === undefined || first === '') continue
259 if (INTERPRETER.test(first)) {
260 let j = i + 1
261 while (j < words.length && (/^-/.test(words[j] ?? '') || words[j] === 'run')) j++
262 const target = words[j]
263 if (target !== undefined && target !== '' && !/^-/.test(target)) out.push(normalize(target, cwd))
264 } else if (/[\\/]/.test(first)) out.push(normalize(first, cwd))
265 }
266
267 return out
268}
269
270/** A script's code checked against the enabled rules, as if each line were a shell command. */
271export function scanScript(code: string, c: Pick<Call, 'root' | 'cwd'>, enabled: string[], custom: GuardConfig['custom'] = []) {
272 const call: Call = { tool: 'Bash', input: { command: code.replace(/\r?\n/g, ' ; ') }, root: c.root, cwd: c.cwd, raw: true }
273 for (const rule of RULES) {
274 if (!enabled.includes(rule.id)) continue
275 const what = rule.test(call)
276 if (what !== undefined) return { rule: rule.title, what }
277 }
278 for (const p of custom) {
279 try {
280 const m = new RegExp(p.pattern, 'i').exec(cmd(call))
281 if (m) return { rule: p.note || `custom /${p.pattern}/`, what: m[0] }
282 } catch {
283 // invalid regex: ignored
284 }
285 }
286
287 return undefined
288}
289
290/** The file's content after an Edit, when we know what it was before. */
291export function applyEdit(before: string, input: Record<string, unknown>) {
292 const from = input.old_string, to = input.new_string
293 if (typeof from !== 'string' || typeof to !== 'string' || from === '') return before
294
295 return input.replace_all === true ? before.split(from).join(to) : before.replace(from, () => to)
296}
297
298/** Block counts for the last `days` days: total, per rule, per project, newest first. */
299export function summarize(log: GuardBlock[], now: number, days = 7) {
300 const recent = log.filter(b => now - b.at < days * DAY)
301 const tally = (key: (b: GuardBlock) => string) =>
302 [...recent.reduce((m, b) => m.set(key(b), (m.get(key(b)) ?? 0) + 1), new Map<string, number>())].sort((a, b) => b[1] - a[1])
303
304 return { total: recent.length, byRule: tally(b => b.rule), byProject: tally(b => b.project ?? '?'), latest: recent.slice(0, 5) }
305}
306
307const PRESETS: { id: string; title: string; rules: string[] }[] = [
308 { id: 'safe', title: 'Safe defaults', rules: ['no-rm-rf', 'no-mass-delete', 'no-force-push', 'no-history-rewrite', 'protect-secrets', 'no-sudo'] },
309 { id: 'locked', title: 'Locked to project', rules: ['no-rm-rf', 'no-mass-delete', 'no-force-push', 'no-history-rewrite', 'protect-secrets', 'no-sudo', 'jail-all'] },
310 { id: 'review', title: 'Read-only review', rules: ['no-mass-delete', 'protect-secrets', 'no-sudo', 'no-installs', 'read-only'] },
311 { id: 'off', title: 'All off', rules: [] },
312]
313
314/**
315 * A saved config from an older version: rules added since then switch on when
316 * the person is on a preset that now includes them; hand-picked sets stay as they are.
317 */
318export function upgrade(cfg: GuardConfig): GuardConfig {
319 const known = new Set(cfg.known ?? ['no-rm-rf', 'no-force-push', 'no-history-rewrite', 'protect-secrets', 'no-sudo', 'no-installs', 'no-network', 'jail-writes', 'jail-all', 'read-only'])
320 const added = RULES.map(r => r.id).filter(id => !known.has(id))
321 const mine = new Set(cfg.enabled)
322 const preset = PRESETS.find(p => {
323 const before = p.rules.filter(id => !added.includes(id))
324
325 return before.length === mine.size && before.every(id => mine.has(id))
326 })
327
328 return { ...cfg, enabled: preset ? [...preset.rules] : cfg.enabled, known: RULES.map(r => r.id) }
329}
330
331async function logBlock($: EngineInterface, block: GuardBlock) {
332 const log = await update($, weekLog, list => [block, ...list].filter(b => block.at - b.at < 30 * DAY).slice(0, 500))
333 await $.store.set(LOG_KEY, log)
334}
335
336async function clearLog($: EngineInterface) {
337 await update($, weekLog, () => [])
338 await $.store.set(LOG_KEY, [])
339}
340
341async function save($: EngineInterface,change: (c: GuardConfig) => GuardConfig) {
342 const next = await update($, config, change)
343 await $.store.set(STORE_KEY, next)
344 const count = next.enabled.length + next.custom.length
345 $.ui.status(count > 0 ? `guard ${count} rules` : undefined)
346}
347
348export const register: Register = on => {
349 on('session.start', async ($, e, next) => {
350 await $.command.register({ name: 'guard', description: 'Open guardrails: toggle safety rules and presets' })
351 await $.command.register({ name: 'guard-preset', description: 'Apply a guardrail preset', argumentHint: 'safe | locked | review | off' })
352 await $.command.register({ name: 'guard-log', description: 'What guardrails blocked in the last 7 days', argumentHint: '[json]' })
353 const stored = (await $.store.get(STORE_KEY)) as GuardConfig | undefined
354 await save($, () => (stored === undefined ? { enabled: PRESETS[0]?.rules ?? [], custom: [], known: RULES.map(r => r.id) } : upgrade(stored)))
355 const savedLog = ((await $.store.get(LOG_KEY)) as GuardBlock[] | undefined) ?? []
356 await update($, weekLog, () => savedLog)
357
358 return next(e)
359 })
360
361 on('command.run', { command: 'guard' }, async $ => {
362 await $.ui.open({ id: PANE, title: 'Guardrails' })
363
364 return { text: 'Guardrails panel opened.' }
365 })
366
367 on('command.run', { command: 'guard-preset' }, async ($, e) => {
368 const preset = PRESETS.find(p => p.id === e.args.trim())
369 if (preset === undefined) return { text: `Unknown preset. Use one of: ${PRESETS.map(p => p.id).join(', ')}` }
370 await save($, c => ({ ...c, enabled: preset.rules }))
371
372 return { text: `Guardrails: ${preset.title} (${preset.rules.length} rules).` }
373 })
374
375 on('command.run', { command: 'guard-log' }, async ($, e) => {
376 const log = await read($, weekLog)
377 const now = await $.clock.now()
378 if (e.args.trim() === 'json') return { text: JSON.stringify(log.filter(b => now - b.at < 7 * DAY), null, 2) }
379 const s = summarize(log, now)
380 if (s.total === 0) return { text: 'Guardrails blocked nothing in the last 7 days.' }
381
382 return {
383 text: [
384 `Guardrails blocked ${s.total} call${s.total === 1 ? '' : 's'} in the last 7 days.`,
385 `By rule: ${s.byRule.map(([r, n]) => `${r} (${n})`).join(', ')}`,
386 `By project: ${s.byProject.map(([p, n]) => `${p} (${n})`).join(', ')}`,
387 'Latest:',
388 ...s.latest.map(b => ` ${new Date(b.at).toISOString().slice(0, 16).replace('T', ' ')} ${b.project ?? ''} ${b.rule}: ${b.what}`),
389 'Stored only on this machine. /guard-log json for the raw list; clear it in /guard.',
390 ].join('\n'),
391 }
392 })
393
394 on('tool.call', async ($, e, next) => {
395 const { tool, tool_use_id: _id, agentId: _agent, ...input } = e as unknown as { tool: string; tool_use_id: string; agentId?: string } & Record<string, unknown>
396 const cfg = await read($, config)
397 if (cfg.enabled.length === 0 && cfg.custom.length === 0) return next(e)
398
399 const call: Call = { tool, input, root: await $.session.root(), cwd: await $.session.cwd() }
400 let broken: { rule: string; what: string } | undefined
401
402 const own = protectSelf(call)
403 if (own !== undefined) broken = { rule: SELF_RULE, what: own }
404
405 for (const rule of broken === undefined ? RULES : []) {
406 if (!cfg.enabled.includes(rule.id)) continue
407 const what = rule.test(call)
408 if (what !== undefined) {
409 broken = { rule: rule.title, what }
410 break
411 }
412 }
413 if (broken === undefined && SHELLS.has(tool)) {
414 for (const custom of cfg.custom) {
415 try {
416 const m = new RegExp(custom.pattern, 'i').exec(cmd(call))
417 if (m) {
418 broken = { rule: custom.note || `custom /${custom.pattern}/`, what: m[0] }
419 break
420 }
421 } catch {
422 // invalid regex: ignored
423 }
424 }
425 }
426 if (broken === undefined && SHELLS.has(tool)) {
427 const known = await read($, scripts)
428 for (const file of scriptRuns(cmd(call), call.cwd)) {
429 const code = known[file]
430 const hit = code === undefined ? undefined : scanScript(code, call, cfg.enabled, cfg.custom)
431 if (hit !== undefined) {
432 broken = { rule: hit.rule, what: `${hit.what} (inside ${file.split('/').pop()}, written this session)` }
433 break
434 }
435 }
436 }
437 if (broken === undefined) {
438 // remember what the agent writes, so running it later can be checked
439 const p = pathOf(call)
440 if (p !== undefined && (tool === 'Write' || tool === 'Edit')) {
441 const file = normalize(p, call.cwd)
442 await update($, scripts, known => {
443 const code = tool === 'Write' ? (typeof input.content === 'string' ? input.content : undefined) : known[file] === undefined ? undefined : applyEdit(known[file], input)
444 if (code === undefined || code.length > SCRIPT_MAX) return known
445 const rest = Object.entries(known).filter(([k]) => k !== file).slice(-29)
446
447 return Object.fromEntries([...rest, [file, code]])
448 })
449 }
450
451 return next(e)
452 }
453
454 const block: GuardBlock = { at: await $.clock.now(), rule: broken.rule, tool, what: broken.what.slice(0, 200), project: call.root.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? '' }
455 await update($, blocks, list => [block, ...list].slice(0, 50))
456 await logBlock($, block)
457 $.ui.toast(`Blocked ${tool}: ${broken.rule}`)
458
459 return {
460 deny: `Blocked by the user's guardrails (rule: "${broken.rule}", matched: ${broken.what.slice(0, 120)}). Do not try to work around this rule with a different command; ask the user if you believe this action is needed.`,
461 }
462 })
463
464 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
465 const els = $.ui.resolve(e)
466 const { Box, Text, Button } = els
467 const Input = 'Input' in els ? els.Input : undefined
468 const cfg = await read($, config)
469 const recent = await read($, blocks)
470 const week = summarize(await read($, weekLog), await $.clock.now())
471
472 const kit = { Box, Text }
473 const isActive = (p: (typeof PRESETS)[number]) => p.rules.length === cfg.enabled.length && p.rules.every(r => cfg.enabled.includes(r))
474 const active = PRESETS.find(isActive)
475 const count = cfg.enabled.length + cfg.custom.length
476 const status =
477 count === 0
478 ? 'all rules off'
479 : `${active?.title ?? 'custom set'} · ${count} rule${count === 1 ? '' : 's'} on${recent.length > 0 ? ` · ${recent.length} blocked` : ''}`
480
481 return (
482 <Box flexDirection="column" gap={1}>
483 {header(kit, count > 0 ? GLYPH.on : GLYPH.off, count > 0 ? TONE.accent : TONE.dim, 'guardrails', status)}
484
485 {section(
486 kit,
487 'Presets',
488 <Box gap={1} flexWrap="wrap">
489 {PRESETS.map((p, i) => (
490 <Button key={`preset-${p.id}`} hotkey={String(i + 1)} variant={isActive(p) ? 'primary' : 'secondary'} label={p.title} onPress={() => void save($, c => ({ ...c, enabled: p.rules }))} />
491 ))}
492 </Box>,
493 )}
494
495 {section(
496 kit,
497 'Rules',
498 RULES.map(rule => {
499 const isOn = cfg.enabled.includes(rule.id)
500
501 return (
502 <Box flexDirection="column">
503 <Box gap={1}>
504 <Text color={isOn ? TONE.accent : TONE.dim}>{isOn ? GLYPH.on : GLYPH.off}</Text>
505 <Button
506 key={`rule-${rule.id}`}
507 plain
508 label={rule.title}
509 onPress={() => void save($, c => ({ ...c, enabled: isOn ? c.enabled.filter(id => id !== rule.id) : [...c.enabled, rule.id] }))}
510 />
511 </Box>
512 <Box paddingLeft={2}>
513 <Text dimColor wrap="truncate-end">
514 {rule.explain}
515 </Text>
516 </Box>
517 </Box>
518 )
519 }),
520 )}
521
522 {section(
523 kit,
524 'Your own block patterns (regex on shell commands)',
525 <Box flexDirection="column">
526 {cfg.custom.length === 0 && empty(kit, String.raw`None yet. Example: terraform\s+destroy or kubectl\s+delete`)}
527 {cfg.custom.map((c, i) => (
528 <Box gap={1}>
529 <Text color={TONE.accent}>{GLYPH.on}</Text>
530 <Text wrap="truncate-end">/{c.pattern}/</Text>
531 <Button key={`del-${i}`} plain label="remove" onPress={() => void save($, cur => ({ ...cur, custom: cur.custom.filter((_, j) => j !== i) }))} />
532 </Box>
533 ))}
534 {Input !== undefined && (
535 <Input
536 key="add"
537 placeholder="regex, e.g. docker\s+system\s+prune"
538 submitLabel="Add"
539 onSubmit={(value: string) => {
540 const pattern = value.trim()
541 if (pattern === '') return
542 try {
543 new RegExp(pattern)
544 } catch {
545 $.ui.toast('That is not a valid regular expression.')
546 return
547 }
548 void save($, c => ({ ...c, custom: [...c.custom, { pattern, note: '' }] }))
549 }}
550 />
551 )}
552 </Box>,
553 )}
554
555 {section(
556 kit,
557 `Blocked this session (${recent.length})`,
558 <Box flexDirection="column">
559 {recent.length === 0 && empty(kit, 'Nothing blocked yet. When a rule stops a command, it shows up here with the rule that caught it.')}
560 {recent.slice(0, 6).map(b => (
561 <Text wrap="truncate-end">
562 <Text color={TONE.bad}>{GLYPH.fail}</Text> {b.tool} <Text dimColor>{b.rule}: {b.what}</Text>
563 </Text>
564 ))}
565 </Box>,
566 )}
567
568 {section(
569 kit,
570 `Last 7 days (${week.total})`,
571 <Box flexDirection="column">
572 {week.total === 0 && empty(kit, 'Nothing blocked this week. Blocks from every session and project add up here; /guard-log prints a summary.')}
573 {week.byRule.slice(0, 5).map(([rule, n]) => (
574 <Box gap={1}>
575 {num(kit, String(n), 4, TONE.bad)}
576 <Text wrap="truncate-end">{rule}</Text>
577 </Box>
578 ))}
579 </Box>,
580 week.total > 0 ? <Button key="clear-log" plain label="clear" onPress={() => void clearLog($)} /> : undefined,
581 )}
582 <Text dimColor>Rules are best-effort pattern checks, a seatbelt, not a sandbox. Scripts the agent writes are checked before they run, and the agent can't edit these settings. Everything stays on this machine.</Text>
583 </Box>
584 )
585 })
586}
587types/index.d.ts 24 lines1export type GuardConfig = {
2 /** ids of built-in rules that are on */
3 enabled: string[]
4 /** your own patterns: Bash commands matching `pattern` (a regex) are blocked */
5 custom: { pattern: string; note: string }[]
6 /** rule ids this config has seen, so rules added later can be switched on for preset users */
7 known?: string[]
8}
9
10export type GuardBlock = { at: number; rule: string; tool: string; what: string; project?: string }
11
12declare module 'claude-code' {
13 interface PluginState {
14 guardrails: {
15 config: GuardConfig
16 blocks: GuardBlock[]
17 /** files the agent wrote this session (normalized path -> content), checked before they run */
18 scripts: Record<string, string>
19 /** blocks from the last 30 days, mirrored to the plugin store */
20 log: GuardBlock[]
21 }
22 }
23}
24