Blocks destructive shell commands and edits to secrets before they run

Small plugins for Claude Code. Each mod is one TypeScript file of function hooks.
<img src="assets/hero.svg" alt="Claude Code with all four mods running: seatbelt blocking a dangerous rm, the session-dash pane, the turn-meter status line and a ding toast" width="900">
turn-meter a turn timer in the status line
seatbelt blocks destructive commands and edits to secrets
ding a chime when a long turn finishes
session-dash /dash, a live pane of what the session did
context-meter a context window meter under the prompt; click it for the breakdown
Also: claude-familiar, a pixel companion that sits above the prompt and reacts to your session, and claude-readout, which names the files on the folded Read 3 files line.
git clone https://github.com/lucenity0/claude-code-mods ~/claude-code-mods
claude --plugin-dir ~/claude-code-mods/seatbelt --plugin-dir ~/claude-code-mods/session-dash
To turn on function hooks and load the mods in every session, set this in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-mods/turn-meter:~/claude-code-mods/seatbelt:~/claude-code-mods/ding:~/claude-code-mods/session-dash:~/claude-code-mods/context-meter"
}
}
These use function hooks, an early-access API that CLAUDE_CODE_ENABLE_FUNCTION_HOOKS turns on, and were tested on Claude Code 2.1.287.
The status line counts up while Claude works. When the turn ends it shows the summary.
✓ 41s · 12.3k in · 1.8k out
on('turn.start', ($, e, next) => {
ticker?.cancel()
let seconds = 0
$.ui.status('running 0s')
ticker = $.clock.every(1000, () => {
seconds += 1
$.ui.status(`running ${formatDuration(seconds * 1000)}`)
})
return next(e)
})
Checks every Bash, Edit and Write call before it runs, and denies the ones that would hurt.
● Bash(rm -rf /tmp/build/../../)
⎿ seatbelt: blocked `rm -rf /tmp/build/../../` (rm -rf on / or ~): it
recursively deletes the filesystem root, a top-level folder or your home folder…
blocks still allows
rm -rf / rm -fr ~ rm -rf /x/../../ rm -rf node_modules
git push -f origin main git push --force origin my-branch
curl … | sh curl -o install.sh …
mkfs dd of=/dev/… chmod -R 777 chmod 755 script.sh
edits to .env *.pem ~/.ssh/* .env.example
Each rule is a small object in rules.ts:
{
name: 'curl | sh',
reason: 'pipes a script from the internet straight into a shell',
matches: command => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(command),
}
It's a guardrail against accidents, not a sandbox.
When a turn that ran longer than 30 seconds finishes, ding shows a toast and plays an original two-note chime. The sound plays on macOS only.
{ "pluginConfigs": { "ding": { "options": { "thresholdSeconds": 60, "sound": false } } } }
Type /dash to open the pane. Type /dash again, or press q, to close it.
╭─ Session ─────────────────────────────╮
│ 4 turns · 2m13s · 184k in · 9.2k out │
│ │
│ Tools (23 calls) │
│ Bash ████████████████████ 9 │
│ Read ███████████████ 7 │
│ Edit █████████ 4 │
│ │
│ Turn time · max 1m12s │
│ ▂▄▃▆▁▅█▃▄▁▆▃ │
│ │
│ Recent files │
│ src/server.ts │
│ README.md │
│ │
│ [ Reset ] [ Close ] │
╰───────────────────────────────────────╯
on('tool.call', async ($, e, next) => {
const ran = await next(e)
await update($, stats, current =>
countTool(current, e.tool, { isDenied: ran.deny !== undefined, isError: ran.isError === true }),
)
return ran
})
A meter sits at the bottom right of the prompt, under the input. It turns yellow at 60% and red at 80%.
ctx ▰▰▰▱▱▱▱▱ 42%
Click it, or type /ctx, to open the breakdown. Esc closes it.
42% 84k of 200k claude-opus-5-5 · estimated
██████████████████████████▌█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒
83k free · compacts in 83k
● Messages 62k ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 74%
● System tools 13k ━━━━━━━━ 15%
● Skills 4.9k ━━━ 6%
● System prompt 3.7k ━━ 4%
○ on demand 93k MCP tools, not in the window
last request 84k in · 82k cached · 967 out
memory
CLAUDE.md ~/code/app 900
[ Count exactly ] [ Refresh ] [ Compact ] [ Close ]
The breakdown is a local estimate. Count exactly counts it with the token-count API, as /context does.
on('session.measure', async ($, e, next) => {
await update($, fill, () => e.context)
if (e.changed.includes('context') && (await isPaneOpen($))) void loadDetails($, 'summary')
return next(e)
})
hello/
├── .claude-plugin/plugin.json { "name": "hello", "version": "0.1.0", "description": "…" }
└── hooks/
├── hooks.json { "modules": ["./register.ts"] }
└── register.ts
import type { Register } from 'claude-code'
export const register: Register = on => {
on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
const result = await next(e)
$.ui.toast(`edited ${e.file_path.split('/').pop()}`)
return result
})
}
claude plugin validate ./hello
claude plugin test ./hello
<sub>MIT · built with Claude Code</sub>
hooks/register.ts 52 lines1import type { Register } from 'claude-code'
2
3import { BASH_RULES, FILE_RULES, firstMatch } from './rules'
4
5function clip(text: string, length = 60): string {
6 const line = text.replace(/\s+/g, ' ').trim()
7 return line.length > length ? `${line.slice(0, length - 1)}…` : line
8}
9
10function checkFile(tool: string, path: string): { deny: string; toast: string } | undefined {
11 const rule = firstMatch(FILE_RULES, path)
12 if (rule === undefined) return undefined
13
14 return {
15 deny: `seatbelt: ${path} ${rule.reason}, so ${tool} is blocked. Ask the user to make this change themselves.`,
16 toast: `seatbelt blocked ${tool} on ${clip(path)}`,
17 }
18}
19
20export const register: Register = on => {
21 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
22 const rule = firstMatch(BASH_RULES, e.command)
23 if (rule === undefined) return next(e)
24
25 $.ui.toast(`seatbelt blocked ${rule.name}: ${clip(e.command)}`)
26 return {
27 deny: `seatbelt: blocked \`${clip(e.command, 200)}\` (${rule.name}): it ${rule.reason}. Do not retry or work around this; if the user really wants it, they can run it themselves.`,
28 }
29 })
30
31 on('tool.call', { tool: 'Edit' }, ($, e, next) => {
32 const blocked = checkFile('Edit', e.file_path)
33 if (blocked === undefined) return next(e)
34 $.ui.toast(blocked.toast)
35 return { deny: blocked.deny }
36 })
37
38 on('tool.call', { tool: 'Write' }, ($, e, next) => {
39 const blocked = checkFile('Write', e.file_path)
40 if (blocked === undefined) return next(e)
41 $.ui.toast(blocked.toast)
42 return { deny: blocked.deny }
43 })
44
45 on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => {
46 const blocked = checkFile('NotebookEdit', e.notebook_path)
47 if (blocked === undefined) return next(e)
48 $.ui.toast(blocked.toast)
49 return { deny: blocked.deny }
50 })
51}
52hooks/rules.ts 142 lines1export type Rule = {
2 name: string
3 reason: string
4 matches: (subject: string) => boolean
5}
6
7/** Collapses `.`, `..` and repeated slashes so `/tmp/x/../../` reads as `/`. */
8export function normalizePath(path: string): string {
9 const isAbsolute = path.startsWith('/')
10 const parts: string[] = []
11 for (const part of path.split('/')) {
12 if (part === '' || part === '.') continue
13 if (part === '..') {
14 if (parts.length > 0 && parts.at(-1) !== '..') parts.pop()
15 else if (!isAbsolute) parts.push('..')
16 continue
17 }
18 parts.push(part)
19 }
20 const joined = parts.join('/')
21 return isAbsolute ? `/${joined}` : joined || '.'
22}
23
24/** Splits a command line into the simple commands it chains or pipes. */
25function segments(command: string): string[][] {
26 return command
27 .split(/;|&&|\|\||\||\n/)
28 .map(segment =>
29 segment
30 .trim()
31 .split(/\s+/)
32 .map(token => token.replace(/^['"]|['"]$/g, ''))
33 .filter(token => token !== ''),
34 )
35 .map(tokens => {
36 let start = 0
37 while (start < tokens.length && (tokens[start] === 'sudo' || /^\w+=/.test(tokens[start] ?? ''))) {
38 start += 1
39 }
40 return tokens.slice(start)
41 })
42 .filter(tokens => tokens.length > 0)
43}
44
45const HOME_SPELLINGS = /^(~|\$HOME|\$\{HOME\})(\/\*?)?$/
46
47/** A target whose loss is the whole machine or the whole home folder. */
48function isSweepingTarget(target: string): boolean {
49 if (HOME_SPELLINGS.test(target)) return true
50 if (!target.startsWith('/')) return false
51 const normalized = normalizePath(target.replace(/\/\*$/, ''))
52 return normalized.split('/').filter(Boolean).length <= 1
53}
54
55function isRecursiveRemoveOfRoot(command: string): boolean {
56 return segments(command).some(tokens => {
57 if (!/(^|\/)rm$/.test(tokens[0] ?? '')) return false
58 const args = tokens.slice(1)
59 const isRecursive = args.some(
60 arg => arg === '--recursive' || (/^-[^-]/.test(arg) && /[rR]/.test(arg)),
61 )
62 return isRecursive && args.filter(arg => !arg.startsWith('-')).some(isSweepingTarget)
63 })
64}
65
66function isForcePushToMain(command: string): boolean {
67 return segments(command).some(tokens => {
68 if (tokens[0] !== 'git' || tokens[1] !== 'push') return false
69 const args = tokens.slice(2)
70 const isForced = args.some(arg => arg === '-f' || arg.startsWith('--force') || /^-[^-]*f/.test(arg))
71 const refs = args.filter(arg => !arg.startsWith('-'))
72 const hitsMain = refs.some(ref => /^\+?(main|master)$|:(main|master)$|^\+(main|master)/.test(ref))
73 return hitsMain && (isForced || refs.some(ref => ref.startsWith('+')))
74 })
75}
76
77export const BASH_RULES: readonly Rule[] = [
78 {
79 name: 'rm -rf on / or ~',
80 reason: 'recursively deletes the filesystem root, a top-level folder or your home folder',
81 matches: isRecursiveRemoveOfRoot,
82 },
83 {
84 name: 'force-push to main',
85 reason: 'rewrites the history of main/master for everyone',
86 matches: isForcePushToMain,
87 },
88 {
89 name: 'curl | sh',
90 reason: 'pipes a script from the internet straight into a shell',
91 matches: command => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(command),
92 },
93 {
94 name: 'mkfs',
95 reason: 'formats a disk',
96 matches: command => /(^|[\s;&|])mkfs(\.\w+)?\b/.test(command),
97 },
98 {
99 name: 'dd to a device',
100 reason: 'writes raw bytes over a disk device',
101 matches: command => /\bdd\b[^;&|]*\bof=\/dev\//.test(command),
102 },
103 {
104 name: 'chmod -R 777',
105 reason: 'makes a whole tree world-writable',
106 matches: command =>
107 segments(command).some(
108 tokens =>
109 tokens[0] === 'chmod' &&
110 tokens.some(t => /^-[^-]*R/.test(t) || t === '--recursive') &&
111 tokens.some(t => /^0?777$/.test(t)),
112 ),
113 },
114 {
115 name: 'fork bomb',
116 reason: 'spawns processes until the machine falls over',
117 matches: command => /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(command),
118 },
119]
120
121export const FILE_RULES: readonly Rule[] = [
122 {
123 name: '.env files',
124 reason: 'holds secrets (.env.example, .env.sample and .env.template are fine)',
125 matches: path => /(^|\/)\.env(\.(?!example$|sample$|template$)[^/]+)?$/.test(path),
126 },
127 {
128 name: 'private keys',
129 reason: 'is a private key or certificate',
130 matches: path => /\.(pem|key|p12|pfx)$/.test(path) || /(^|\/)id_(rsa|ed25519|ecdsa|dsa)[^/]*$/.test(path),
131 },
132 {
133 name: '~/.ssh',
134 reason: 'is inside your SSH folder',
135 matches: path => /(^|\/)\.ssh\//.test(path),
136 },
137]
138
139export function firstMatch(rules: readonly Rule[], subject: string): Rule | undefined {
140 return rules.find(rule => rule.matches(subject))
141}
142