Blocks .env access and destructive shell commands, with a red line saying why

guard is a Claude Code mod that watches every tool call Claude Code is about to make and stops anything that touches a .env file or does something destructive. It checks before the call runs, not after, so a blocked command never executes. When it blocks something, a red line in the transcript says what was blocked and why, and Claude is told not to retry or work around it.
.env files in every form: reads, writes, edits, greps, globs, shell commands that name one (cat .env, grep KEY prod.env), and @.env mentions in your prompt. Covers .env, .env.local, .env.production, prod.env and the like.rm -rf (in any flag order), git reset --hard, git clean -f, git push --force, git checkout ., find -delete, dd of=, mkfs, writing to a raw disk, chmod -R 777, and fork bombs.rm -rf runs when every target is a build folder that gets recreated anyway: node_modules, dist, build, out, .next, .nuxt, .turbo, .cache, .parcel-cache, coverage, .vite, .svelte-kit. rm -rf node_modules src is still blocked, and so is any target with .., a glob, ~ or a variable in it..env.example, .env.sample, .env.template and .env.dist, since they hold no secrets.rm file.txt and rm -r build. Only the forced recursive form is blocked.Requires Claude Code 2.1.292 or later.
The shortest path, from inside Claude Code:
/plugin install guard --marketplace kju4q/guard
Answer y to add the marketplace, then pick a scope. It's active right away, no restart.
Or clone it and point Claude Code at the folder:
git clone https://github.com/kju4q/guard.git
claude --plugin-dir ./guard
To change what it blocks, edit hooks/rules.ts.
echo "rm -rf src" > notes.txt is stopped even though nothing would be deleted. That's intentional for a safety check.hooks/register.tsx 34 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Blocked } from '../types'
5import { touchesEnv, verdict } from './rules'
6
7const blocked = atom({ plugin: 'guard', key: 'blocked' } as const, {} as Blocked)
8
9export const register: Register = on => {
10 on('tool.call', async ($, e, next) => {
11 const reason = verdict(e.tool, e as unknown as Record<string, unknown>)
12 if (!reason) return next(e)
13
14 await update($, blocked, b => ({ ...b, [e.tool_use_id]: reason }))
15 $.ui.log(`guard blocked: ${reason}`, { to: 'debug' })
16 return { deny: `Blocked by guard: ${reason}. Do not retry or work around this.` }
17 }).catch(($, e, next) =>
18 next.called ? next(e) : { deny: 'guard: its check failed, so the call was blocked.' },
19 )
20
21 on('prompt.mention', ($, e, next) => {
22 const env = touchesEnv(e.path)
23 return env ? { deny: `guard: ${env} holds secrets` } : next(e)
24 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'guard: its check failed.' }))
25
26 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
27 const reason = (await read($, blocked))[e.requestId]
28 if (!reason) return next(e)
29
30 const { Text } = $.ui.resolve(e)
31 return <Text color="red" bold>✕ Blocked: {reason}</Text>
32 })
33}
34hooks/rules.ts 89 lines1// A .env file: .env, .env.local, prod.env. Templates (.env.example etc.) stay readable.
2const ENV = /(^|[^\w.-])([\w-]*\.env|\.env(\.[\w.-]+)?)(?=$|[^\w.-])/g
3const TEMPLATE = /\.(example|sample|template|dist)$/
4
5export function touchesEnv(text: string): string | undefined {
6 for (const m of text.matchAll(ENV)) {
7 const name = m[2]!
8 if (!TEMPLATE.test(name)) return name
9 }
10 return undefined
11}
12
13const DESTRUCTIVE: ReadonlyArray<[RegExp, string]> = [
14 [/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b/, 'rm -rf deletes recursively with no confirmation'],
15 [/\brm\s+(-\w+\s+)*(-r|-R|--recursive)\b.*\s(-f|--force)\b/, 'rm -r -f deletes recursively with no confirmation'],
16 [/\brm\s+(-\w+\s+)*(-f|--force)\b.*\s(-r|-R|--recursive)\b/, 'rm -f -r deletes recursively with no confirmation'],
17 [/\bgit\s+reset\s+--hard\b/, 'git reset --hard throws away uncommitted work'],
18 [/\bgit\s+clean\s+-\w*f/, 'git clean -f deletes untracked files'],
19 [/\bgit\s+push\b.*(\s-f\b|--force\b)/, 'force push rewrites remote history'],
20 [/\bgit\s+checkout\s+(--\s+)?\.(\s|$)/, 'git checkout . discards every local change'],
21 [/\bfind\b.*\s-delete\b/, 'find -delete removes every match'],
22 [/\bdd\b.*\bof=/, 'dd of= overwrites a file or disk'],
23 [/\bmkfs(\.\w+)?\b/, 'mkfs formats a filesystem'],
24 [/>\s*\/dev\/(sd|disk|nvme)/, 'writing straight to a disk device'],
25 [/\bchmod\s+-R\s+777\b/, 'chmod -R 777 opens every file to everyone'],
26 [/:\(\)\s*\{\s*:\|:&\s*\};:/, 'fork bomb'],
27]
28
29// Folders a build or install recreates, so rm -rf on them loses nothing.
30const REBUILDABLE = new Set([
31 'node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo',
32 '.cache', '.parcel-cache', 'coverage', '.vite', '.svelte-kit',
33])
34
35const RM = /^\s*rm\s/
36
37// True when a lone rm names only rebuildable folders: plain relative or
38// absolute paths, no globs, variables, subshells, quotes, ~ or `..`.
39function onlyRebuildable(segment: string): boolean {
40 const words = segment.trim().split(/\s+/).slice(1)
41 const paths = words.filter(w => !w.startsWith('-'))
42 if (paths.length === 0) return false
43 return paths.every(p => {
44 if (/[*?[\]{}$`~'"\\]/.test(p)) return false
45 const parts = p.replace(/\/+$/, '').split('/').filter(s => s !== '' && s !== '.')
46 if (parts.includes('..') || parts.length === 0) return false
47 return REBUILDABLE.has(parts[parts.length - 1]!)
48 })
49}
50
51export function destructive(command: string): string | undefined {
52 // Judge each command of a chain on its own, so `rm -rf dist && rm -rf src` still blocks.
53 for (const segment of command.split(/&&|\|\||[;|\n&]/)) {
54 for (const [re, why] of DESTRUCTIVE) {
55 if (!re.test(segment)) continue
56 if (RM.test(segment) && onlyRebuildable(segment)) continue
57 return why
58 }
59 }
60 return undefined
61}
62
63// What the call reaches, as text, per tool; undefined for tools that touch no files.
64export function targets(tool: string, input: Record<string, unknown>): string[] {
65 const keys: Record<string, string[]> = {
66 Bash: ['command'],
67 Read: ['file_path'],
68 Write: ['file_path'],
69 Edit: ['file_path'],
70 MultiEdit: ['file_path'],
71 NotebookEdit: ['notebook_path'],
72 Grep: ['path', 'glob'],
73 Glob: ['pattern', 'path'],
74 }
75 return (keys[tool] ?? []).map(k => input[k]).filter((v): v is string => typeof v === 'string')
76}
77
78export function verdict(tool: string, input: Record<string, unknown>): string | undefined {
79 for (const t of targets(tool, input)) {
80 const env = touchesEnv(t)
81 if (env) return `${tool} touches ${env}, which holds secrets`
82 }
83 if (tool === 'Bash' && typeof input.command === 'string') {
84 const why = destructive(input.command)
85 if (why) return `${why}: ${input.command.slice(0, 80)}`
86 }
87 return undefined
88}
89types/index.d.ts 10 lines1export type Blocked = Readonly<Record<string, string>>
2
3declare module 'claude-code' {
4 interface PluginState {
5 guard: {
6 blocked: Blocked
7 }
8 }
9}
10