Blocks shell commands that read secret files, change data/raw, or recursively delete protected folders (Bash, PowerShell and their -c wrappers).

A Claude Code mod that checks every Bash and PowerShell command before it runs, and refuses three kinds:
.env, .env.local, .env.*.local, a *.pem file, a private SSH key (id_rsa, id_ed25519, …) or a path under ~/.ssh. .env.example, Test-Path .env, ls, uv run --env-file .env and echo .env >> .gitignore still work.data/raw is read-only. Deleting, moving, renaming, in-place editing (sed -i), redirecting output into it, copying into it, or find … -delete there. Reading from it and copying out of it still work..git folder., .. and *It also refuses pwsh -EncodedCommand, which it cannot read.
It looks inside pwsh -Command, powershell -c, bash -c, cmd /c, eval and Invoke-Expression. When it refuses a command, Claude is told why and to ask you to run it yourself.
It pairs with permission deny rules such as Read(///.env) and Edit(///data/raw/**) in ~/.claude/settings.json. Those rules cover Claude's file tools; this mod covers the shell.
Needs Claude Code 2.1.287 or later with function hooks on (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
At the prompt of a terminal session:
/plugin install guardrails --marketplace daldaman/claude-guardrails
From a shell:
claude plugin install guardrails --marketplace daldaman/claude-guardrails
In the desktop app, click + next to the prompt box, then Plugins. Choose Add marketplace with daldaman/claude-guardrails, then Add plugin.
Projects folder (projectsFolder) is a folder that holds your projects, one per subfolder, such as ~/code. Once it's set, neither that folder nor any whole project in it can be deleted recursively. It's empty by default. Set it at the prompt:
/plugin configure guardrails@guardrails
Or from a shell:
echo '{"projectsFolder":"~/code"}' | claude plugin configure guardrails@guardrails --values-stdin
A session that was already open when you installed the mod doesn't know about it, so its /plugin configure says the mod isn't installed. Use the shell line, or a new session.
rm -rf myproject, typed from inside your projects folder, gets through.Get-ChildItem … | Remove-Item.data/raw (curl -o) on purpose.claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.ts 265 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Shell-side companion to the permission deny rules in ~/.claude/settings.json.
4// Those rules stop Claude's file tools from reading secrets or editing data/raw, and
5// stop git force-push/reset/clean; they cannot see what a Bash or PowerShell command
6// does with those paths. This guard reads each command (and any pwsh -Command,
7// bash -c, cmd /c, eval or Invoke-Expression payload inside it) and refuses three
8// things: touching a secret file, changing data/raw, and a recursive delete of a
9// protected folder. It fails open: a hook that throws is skipped, so a bug here can
10// never brick every shell call the way a fail-closed guard would.
11
12export type Shell = 'bash' | 'pwsh' | 'cmd'
13type Segment = { words: string[]; redirects: string[] }
14
15const END = `(?=$|[\\s'"\`;|&)>,:])`
16const SECRET = [
17 new RegExp(`(^|[\\s'"=/:(,])\\.env(\\.local|\\.[a-z0-9_-]+\\.local)?${END}`), // .env, .env.local, .env.*.local; not .env.example
18 new RegExp(`\\.pem${END}`),
19 new RegExp(`(^|[\\s'"/=])id_(rsa|ed25519|ecdsa|dsa)${END}`), // private keys; id_*.pub stays readable
20 /\/\.ssh(\/|$|[\s'"])/,
21]
22// Commands that name a secret path without printing its contents.
23const SECRET_EXEMPT = new Set(['test-path', 'test', '[', '[[', 'ls', 'dir', 'gci', 'get-childitem', 'ssh', 'scp', 'sftp', 'ssh-add', 'ssh-agent', 'ssh-keygen'])
24const GIT_EXEMPT = new Set(['check-ignore', 'ls-files', 'status', 'rm'])
25const IGNORE_WRITERS = new Set(['echo', 'printf', 'add-content', 'ac', 'write-output', 'out-file', 'set-content', 'tee'])
26
27const RAW = /(^|[/=])data\/raw(\/|$)/
28const MODIFY = new Set([
29 'rm', 'rmdir', 'unlink', 'shred', 'truncate', 'touch', 'chmod', 'chown', 'ln', 'tee', 'mv', 'move', 'rename', 'ren', 'dd',
30 'remove-item', 'ri', 'del', 'erase', 'rd', 'clear-content', 'clc', 'set-content', 'sc', 'add-content', 'ac', 'out-file',
31 'new-item', 'ni', 'rename-item', 'rni', 'move-item', 'mi', 'tee-object', 'attrib', 'icacls',
32])
33const COPY = new Set(['cp', 'copy', 'copy-item', 'cpi', 'xcopy', 'robocopy', 'rsync', 'install'])
34const DELETE = new Set(['rm', 'rmdir', 'rd', 'remove-item', 'ri', 'del', 'erase', 'unlink', 'shred'])
35const PREFIXES = /^(sudo|command|exec|nohup|time|builtin|xargs)$/
36
37/** Splits a command into simple commands at ; | & newlines, parentheses and braces, keeping quoted text whole. */
38export function segments(command: string, shell: Shell): Segment[] {
39 const out: Segment[] = []
40 let seg: Segment = { words: [], redirects: [] }
41 let cur = ''
42 let has = false
43 let quote = ''
44 let redirect = false
45 const word = () => {
46 if (has) (redirect ? seg.redirects : seg.words).push(cur)
47 if (has) redirect = false
48 cur = ''
49 has = false
50 }
51 const cut = () => {
52 word()
53 redirect = false
54 if (seg.words.length || seg.redirects.length) out.push(seg)
55 seg = { words: [], redirects: [] }
56 }
57 for (let i = 0; i < command.length; i++) {
58 const c = command[i]!
59 if (quote) {
60 if (c === quote) quote = ''
61 else cur += c
62 continue
63 }
64 if (c === '"' || c === "'") {
65 quote = c
66 has = true
67 continue
68 }
69 if (c === '`') {
70 if (shell === 'bash') cut() // command substitution
71 else if (shell === 'pwsh') { cur += command[++i] ?? ''; has = true } // escape character
72 else { cur += c; has = true }
73 continue
74 }
75 if (c === '{' && cur.endsWith('$')) { // ${HOME}, ${env:USERPROFILE}
76 const close = command.indexOf('}', i)
77 const end = close < 0 ? command.length : close + 1
78 cur += command.slice(i, end)
79 i = end - 1
80 continue
81 }
82 if (c === ' ' || c === '\t' || c === '\r' || c === '<') {
83 word()
84 continue
85 }
86 if (c === '>') {
87 if (/^[0-9*&]?$/.test(cur)) { cur = ''; has = false } else word() // 2> names a stream, not a word
88 if (command[i + 1] === '>') i++
89 if (command[i + 1] === '&') { i += 2; continue } // 2>&1 redirects to a stream, not a file
90 redirect = true
91 continue
92 }
93 if (';|&\n(){}'.includes(c)) {
94 cut()
95 continue
96 }
97 cur += c
98 has = true
99 }
100 cut()
101 return out
102}
103
104/** A path as one comparable form: lower case, forward slashes, /c/... for drives, home expanded, trailing /* and / dropped. */
105export function norm(word: string, home: string): string {
106 let p = word.replace(/\\/g, '/').toLowerCase().trim()
107 if (home) p = p.replace(/^(~|\$home|\$\{home\}|\$env:userprofile|\$\{env:userprofile\}|%userprofile%)(?=\/|$)/, home)
108 p = p.replace(/^\/mnt\/([a-z])(?=\/|$)/, '/$1').replace(/^([a-z]):(?=\/|$)/, '/$1').replace(/\/{2,}/g, '/')
109 if (p === '/*' || p === '/**') return '/'
110 while (p.length > 1 && /.\/(\*\*?|\.)?$/.test(p)) p = p.replace(/\/(\*\*?|\.)?$/, '')
111 return p
112}
113
114/** Where paths are judged from: the home folder and the optional projects folder, both already normalized. */
115export type Ctx = { home: string; projects: string }
116
117// `root` itself, or a folder directly inside it.
118function isAtOrJustUnder(p: string, root: string): boolean {
119 return p === root || (p.startsWith(`${root}/`) && !p.slice(root.length + 1).includes('/'))
120}
121
122/**
123 * Folders no recursive delete may name: drive roots and system folders; home, every
124 * folder directly in it, and every folder directly in a OneDrive folder (where
125 * Windows moves Desktop and Documents); the projects folder and each project in it;
126 * any .git; and the current or a parent directory.
127 */
128export function isProtected(p: string, { home, projects }: Ctx): boolean {
129 if (['', '/', '.', '*', '~', '.git'].includes(p) || /^(\.\.\/)*\.\.$/.test(p)) return true
130 if (/^\/[a-z]$/.test(p) || /(^|\/)\.git$/.test(p)) return true
131 if (/^\/[a-z]\/(users|windows|program files|program files \(x86\)|programdata)$/.test(p) || /^\/[a-z]\/windows\//.test(p)) return true
132 if (home && isAtOrJustUnder(p, home)) return true
133 if (home && p.startsWith(`${home}/onedrive`) && /^[^/]+\/[^/]+$/.test(p.slice(home.length + 1))) return true
134 return projects !== '' && isAtOrJustUnder(p, projects)
135}
136
137function verbOf(word: string): string {
138 return (word.replace(/\\/g, '/').split('/').pop() ?? '').toLowerCase().replace(/\.exe$/, '')
139}
140
141function isFlag(arg: string, shell: Shell): boolean {
142 return arg.startsWith('-') || (shell === 'cmd' && /^\/[a-z]$/i.test(arg)) // cmd switches are /s, /q; its paths are C:\...
143}
144
145function isRecursive(verb: string, lower: string[], shell: Shell): boolean {
146 if (shell === 'cmd') return ['rd', 'rmdir', 'del', 'erase'].includes(verb) && lower.includes('/s')
147 return lower.some(a =>
148 a === '--recursive' ||
149 (/^-[a-z]+$/.test(a) && a.includes('r') && (shell === 'bash' || /^-[rf]+$/.test(a))) || // rm -rf, -Rf, -r
150 (shell === 'pwsh' && a.length >= 2 && '-recurse'.startsWith(a)), // Remove-Item -Recurse, -Rec, -r
151 )
152}
153
154function checkSegment(seg: Segment, shell: Shell, ctx: Ctx, depth: number): string | undefined {
155 const { home } = ctx
156 const words = seg.words.slice()
157 while (words.length && (PREFIXES.test(words[0]!.toLowerCase()) || /^[a-z_][a-z0-9_]*=/i.test(words[0]!))) {
158 const dropped = words.shift()!.toLowerCase()
159 if (dropped === 'xargs') while (words.length && words[0]!.startsWith('-')) words.shift()
160 }
161 const verb = verbOf(words[0] ?? '')
162 const args = words.slice(1)
163 const lower = args.map(a => a.toLowerCase())
164
165 // Wrappers: check the command they run.
166 if (depth < 4) {
167 if (verb === 'pwsh' || verb === 'powershell') {
168 const flags = lower.map(a => a.replace(/^\//, '-'))
169 if (flags.some(a => a === '-ec' || (a.length >= 2 && '-encodedcommand'.startsWith(a))))
170 return 'runs an encoded PowerShell command, which this guard cannot read'
171 const at = flags.findIndex(a => (a.length >= 2 && '-command'.startsWith(a)) || a === '-cwa' || a.startsWith('-commandwithargs'))
172 if (at >= 0) return check(args.slice(at + 1).join(' '), 'pwsh', ctx, depth + 1)
173 }
174 if (['bash', 'sh', 'zsh', 'dash', 'ksh'].includes(verb)) {
175 const at = lower.findIndex(a => /^-[a-z]*c$/.test(a))
176 if (at >= 0) return check(args.slice(at + 1).join(' '), 'bash', ctx, depth + 1)
177 }
178 if (verb === 'cmd') {
179 const at = lower.findIndex(a => a === '/c' || a === '/k')
180 if (at >= 0) return check(args.slice(at + 1).join(' '), 'cmd', ctx, depth + 1)
181 }
182 if (verb === 'eval' || verb === 'invoke-expression' || verb === 'iex') return check(args.join(' '), shell, ctx, depth + 1)
183 }
184
185 // 1. Secret files.
186 const text = [...words, ...seg.redirects].join(' ').replace(/\\/g, '/').toLowerCase()
187 const exempt =
188 SECRET_EXEMPT.has(verb) ||
189 (verb === 'git' && GIT_EXEMPT.has(lower[0] ?? '')) ||
190 (IGNORE_WRITERS.has(verb) && text.includes('.gitignore'))
191 if (!exempt && SECRET.some(r => r.test(text.replace(/--env-file(=|\s+)\S+/g, ' '))))
192 return 'touches a secret file (.env, .env.local, *.pem, an SSH key or ~/.ssh)'
193
194 // 2. data/raw is read-only.
195 const isRaw = (w: string) => RAW.test(norm(w, home))
196 const skipValue = new Set(['-include', '-exclude', '-filter'])
197 const targets = args.filter((a, i) => !isFlag(a, shell) && !skipValue.has(lower[i - 1] ?? ''))
198 if (seg.redirects.some(isRaw)) return 'writes into data/raw, which is read-only'
199 if (MODIFY.has(verb) && targets.some(isRaw)) return 'changes or deletes files in data/raw, which is read-only'
200 if ((verb === 'sed' || verb === 'perl') && lower.some(a => /^-[a-z]*i/.test(a)) && targets.some(isRaw))
201 return 'edits files in data/raw in place, which is read-only'
202 if (COPY.has(verb)) {
203 const named = lower.findIndex(a => a.length >= 3 && '-destination'.startsWith(a))
204 const dest = named >= 0 ? args[named + 1] : verb === 'robocopy' || verb === 'copy-item' || verb === 'cpi' ? targets[1] : targets[targets.length - 1]
205 if (dest !== undefined && isRaw(dest)) return 'copies into data/raw, which is read-only'
206 }
207 if (verb === 'find' && (lower.includes('-delete') || (lower.includes('-exec') && lower.some(a => a === 'rm' || a === 'remove-item')))) {
208 const base = targets[0] ?? '.'
209 if (isRaw(base)) return 'deletes files in data/raw, which is read-only'
210 if (isProtected(norm(base, home), ctx)) return `deletes files across a protected folder (${norm(base, home)})`
211 }
212
213 // 3. Recursive deletes of protected folders.
214 if (DELETE.has(verb) && isRecursive(verb, lower, shell)) {
215 const hit = targets.map(t => norm(t, home)).find(p => isProtected(p, ctx))
216 if (hit !== undefined) return `recursively deletes a protected folder (${hit || '/'})`
217 }
218 return undefined
219}
220
221/** Why the command is refused, or undefined when it may run. */
222export function check(command: string, shell: Shell, ctx: Ctx, depth = 0): string | undefined {
223 for (const seg of segments(command, shell)) {
224 const why = checkSegment(seg, shell, ctx, depth)
225 if (why) return why
226 }
227 return undefined
228}
229
230let home: Promise<string> | undefined
231
232async function homeDir($: EngineInterface): Promise<string> {
233 const raw = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
234 return raw ? norm(raw, '') : ''
235}
236
237/** Forgets the cached home folder, for tests. */
238export function resetHome() {
239 home = undefined
240}
241
242function refusal(name: string, why: string): string {
243 return `${name}: blocked, because this command ${why}. If it is really needed, ask the user to run it themselves.`
244}
245
246async function context($: EngineInterface, projectsFolder: string): Promise<Ctx> {
247 home ??= homeDir($).catch(() => '')
248 const h = await home
249 return { home: h, projects: projectsFolder ? norm(projectsFolder, h) : '' }
250}
251
252export const register: Register = (on, options) => {
253 const projectsFolder = String((options as Record<string, unknown> | undefined)?.projectsFolder ?? '').trim()
254
255 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
256 const why = check(e.command, 'bash', await context($, projectsFolder))
257 return why ? { deny: refusal($.plugin.name, why) } : next(e)
258 })
259
260 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
261 const why = check(e.command, 'pwsh', await context($, projectsFolder))
262 return why ? { deny: refusal($.plugin.name, why) } : next(e)
263 })
264}
265