SLOPSHOPPER

guardrails

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

guard
v0.1.1MITupdated 2026-10-20daldaman/claude-guardrails
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guardrails
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by guardrails: guardrails: blocked, because this command touches a secret file (.env, .env.local, * ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

guardrails

A Claude Code mod that checks every Bash and PowerShell command before it runs, and refuses three kinds:

  • Secret files. Anything that names .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.
  • Recursive deletes of protected folders:
  • drive roots and Windows system folders
  • your home folder, every folder directly in it, and every folder directly in a OneDrive folder
  • your projects folder (optional) and every whole project in it
  • any .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.

Install

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.

Setting

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.

Limits

  • It can't tell where a relative path points, because a hook doesn't see the shell's current directory. rm -rf myproject, typed from inside your projects folder, gets through.
  • It doesn't see paths that arrive on a pipe, as in Get-ChildItem … | Remove-Item.
  • It doesn't see what a script does inside its own code.
  • It allows downloads into data/raw (curl -o) on purpose.
  • If the mod itself errors, the command runs. That way a bug in it can never block every shell call.

Develop

claude plugin validate .
claude plugin test .

License

MIT. See LICENSE.

Source 1 files
hooks/register.ts 265 lines
1import 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