Pattern-based checks that deny .env reads, git force pushes and rm -rf on / or your home folder before Claude Code runs them.

A small Claude Code plugin (a mod, Claude Code 2.1.287+) that checks each tool call and denies three kinds of call before they run:
.env files: Read, Grep and Glob on .env, .env.local, .env.production and similar, plus Bash readers such as cat, head, grep, cp, source and base64 on those files. .env.example, .env.sample and .env.template are allowed.git push -f, --force, --force-with-lease, combined short flags like -uf, and +refspec pushes. A plain git push runs.rm on /, ~ or $HOME: for example rm -rf ~, rm -fr /, rm -Rf $HOME/*. rm -rf node_modules runs.It is pattern-based and about 75 lines of dependency-free JavaScript (hooks/lite-core.mjs). Read it before you install it.
claude plugin marketplace add edtrackai/claude-code-guardrails-lite
claude plugin install guardrails-lite@guardrails-lite
Or inside a session: /plugin marketplace add edtrackai/claude-code-guardrails-lite, then /plugin install guardrails-lite@guardrails-lite. Run /reload-plugins or start a new session.
To try it for one session without installing: clone the repo and run claude --plugin-dir ./claude-code-guardrails-lite.
Output from the core check (node against hooks/lite-core.mjs, captured from this repo's tests):
{"file_path":"/app/.env.local"} -> {"decision":"deny","reason":"Guardrails Lite: reading /app/.env.local is blocked because .env files hold secrets. Use .env.example for variable names, or ask the user for the specific value."}
{"command":"git push --force origin main"} -> {"decision":"deny","reason":"Guardrails Lite: force push needs the user's explicit approval. Ask the user to run it."}
{"command":"rm -rf ~"} -> {"decision":"deny","reason":"Guardrails Lite: recursive rm on /, ~ or $HOME is blocked. Ask the user to run it."}
{"command":"git push origin main"} -> null
In the mod, the reason is returned to Claude Code as { deny: reason } and the tool does not run (checked by the a refused call never reaches the tool plugin test).
claude plugin validate --strict .: passes (Claude Code 2.1.300).claude plugin test .: 13 of 13 pass (tests/register.test.ts, synthetic tool-call events).node --test tests/: 36 of 36 pass (tests/core.test.mjs), including checks of the internal-error deny and of the .catch handler and a timing check that runs 60 adversarial inputs near the 64 K size cap and requires each to finish in under 50 ms.It has not been tested in a live interactive Claude Code session.
.env, git -C dir push -f variants it doesn't parse, shell variables or aliases, find / -delete, or rm -rf on other important folders.Read, Grep, Glob and Bash tool calls. MCP tools, other file tools and anything outside Claude Code aren't checked.Guardrails Lite: internal error ... to stderr (checked by a unit test in tests/core.test.mjs). Ask the user to run the call, or report the error..catch handler is skipped, and the call goes ahead. From 0.1.2 the hook has an on(...).catch(...) handler; per the same docs, Claude Code calls it when the hook throws or times out, and it denies the call if the hook had not yet passed it on. Unit tests check that the handler is attached and how it answers a simulated timeout; a real timeout has not been triggered in a live Claude Code session. If the handler itself fails, the docs say Claude Code skips the hook and the call goes ahead.--safe-mode, --bare or "disableAllHooks": true, or where your organization blocks user mods. Another mod that approves tool calls can approve a call before this one sees it.See CHANGELOG.md.
MIT. See LICENSE.
Ad (paid, from the same author): the Guardrails Pack ($24; UK buyers pay $28.80 incl. VAT) has the full rule set (Supabase, Stripe, deploy and secret-egress checks, settings hooks, Cursor hooks): https://edtrackai.github.io/guardrails?utm_source=github&utm_medium=repo&utm_campaign=sprint1&utm_content=e38-mod
Claude and Claude Code are trademarks of Anthropic. This plugin is not affiliated with or endorsed by Anthropic.
hooks/register.mjs 44 lines1// Guardrails Lite as a Claude Code mod (Claude Code 2.1.287+).
2import { evaluate } from './lite-core.mjs'
3
4const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId'])
5
6/** Turn a mod tool.call event into the {tool_name, tool_input} shape the core takes. */
7export function toHookInput(e) {
8 const tool_input = {}
9 for (const k of Object.keys(e ?? {})) if (!RESERVED.has(k)) tool_input[k] = e[k]
10 return { tool_name: String(e?.tool ?? ''), tool_input }
11}
12
13/** Returns the deny reason, or null to let the call through. */
14export function decide(e) {
15 const d = evaluate(toHookInput(e))
16 return d ? d.reason : null
17}
18
19export function register(on) {
20 on('tool.call', async ($, e, next) => {
21 let reason
22 try {
23 reason = decide(e)
24 } catch (err) {
25 // Fail closed: if the check itself errors, deny the call rather than let it through unchecked.
26 const msg = err && err.message ? err.message : String(err)
27 process.stderr.write(`Guardrails Lite: internal error while checking a tool call, denying it: ${msg}\n`)
28 return { deny: `Guardrails Lite: internal error while checking this call (${msg}), so it was denied. Ask the user to run it, or report the error.` }
29 }
30 if (reason) return { deny: reason }
31 return next(e)
32 }).catch(onHookFailure)
33}
34
35/**
36 * .catch handler: Claude Code calls it when the tool.call hook above throws, times out
37 * or returns a bad shape. Before next ran, deny the call; after, keep the result next produced.
38 */
39export async function onHookFailure($, e, next) {
40 if (next && next.called) return next(e)
41 const kind = (next && next.error && next.error.kind) || 'unknown'
42 return { deny: `Guardrails Lite: the check failed (${kind}), so this call was denied. Ask the user to run it, or report the error.` }
43}
44hooks/lite-core.mjs 77 lines1// Guardrails Lite: pure decision logic. No dependencies. Node 18+.
2// Three checks: secret .env reads, git force push, rm -rf on /, ~ or $HOME.
3// Every check is linear-time: split on separators/whitespace, then Set lookups,
4// startsWith and simple anchored regexes with no nested quantifiers.
5// Inputs over MAX_INPUT (64 K characters) are denied unchecked so a huge input can't stall the check.
6
7export const MAX_INPUT = 64 * 1024
8
9const READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'nl', 'strings', 'grep', 'rg', 'awk', 'sed', 'cp', 'scp', 'base64', 'xxd', 'source', '.'])
10
11/** True for .env, .env.local, path/to/.env.production; false for .env.example/.sample/.template. */
12export function isSecretEnvName(name) {
13 let t = String(name).replace(/\\/g, '/').replace(/^@+/, '').replace(/^["']+|["']+$/g, '')
14 const slash = t.lastIndexOf('/')
15 if (slash >= 0) t = t.slice(slash + 1)
16 if (t === '.env' || t === '.env*' || t === '.env.*') return true
17 if (!t.startsWith('.env.')) return false
18 const parts = t.slice(5).split('.')
19 if (parts.some((p) => !/^[\w*-]+$/.test(p))) return false
20 return !['example', 'sample', 'template'].includes(parts[parts.length - 1].toLowerCase())
21}
22
23function segments(cmd) {
24 return cmd.split(/[;&|\n]+/).map((s) => s.trim().split(/\s+/).filter(Boolean))
25}
26
27function forcePush(w) {
28 const gp = w.findIndex((x, i) => x === 'git' && w[i + 1] === 'push')
29 if (gp < 0) return false
30 return w.slice(gp + 2).some((x) => x === '-f' || x.startsWith('--force') || (x.startsWith('-') && !x.startsWith('--') && x.includes('f')) || (x.startsWith('+') && x.length > 1))
31}
32
33const RM_TARGETS = new Set(['/', '/*', '~', '~/', '~/*', '$HOME', '$HOME/', '$HOME/*', '${HOME}', '${HOME}/', '${HOME}/*', '"$HOME"', '"${HOME}"'])
34function rmRecursiveRootOrHome(w) {
35 const r = w.indexOf('rm')
36 if (r < 0) return false
37 const args = w.slice(r + 1)
38 const recursive = args.some((x) => x === '--recursive' || (x.startsWith('-') && !x.startsWith('--') && /[rR]/.test(x)))
39 return recursive && args.some((x) => RM_TARGETS.has(x))
40}
41
42function readsSecretFile(w) {
43 const r = w.findIndex((x) => READERS.has(x))
44 return r >= 0 && w.slice(r + 1).some(isSecretEnvName)
45}
46
47const deny = (reason) => ({ decision: 'deny', reason })
48
49/**
50 * @param {{tool_name: string, tool_input: Record<string, any>}} input
51 * @returns {{decision: 'deny', reason: string} | null}
52 */
53export function evaluate(input) {
54 const tool = String(input?.tool_name ?? '')
55 const ti = input?.tool_input ?? {}
56
57 if (tool === 'Read' || tool === 'Grep' || tool === 'Glob') {
58 const targets = [ti.file_path, ti.path, tool === 'Glob' ? ti.pattern : undefined, ti.glob].filter((x) => typeof x === 'string')
59 for (const t of targets) {
60 if (t.length > MAX_INPUT) return deny('Guardrails Lite: the path or pattern is too large to check.')
61 if (t.split(/[\s,{}]+/).some(isSecretEnvName)) return deny(`Guardrails Lite: reading ${t} is blocked because .env files hold secrets. Use .env.example for variable names, or ask the user for the specific value.`)
62 }
63 return null
64 }
65
66 if (tool === 'Bash') {
67 const cmd = typeof ti.command === 'string' ? ti.command : ''
68 if (cmd.length > MAX_INPUT) return deny(`Guardrails Lite: the command is over ${MAX_INPUT / 1024} K characters, too large to check.`)
69 for (const w of segments(cmd)) {
70 if (forcePush(w)) return deny('Guardrails Lite: force push needs the user\'s explicit approval. Ask the user to run it.')
71 if (rmRecursiveRootOrHome(w)) return deny('Guardrails Lite: recursive rm on /, ~ or $HOME is blocked. Ask the user to run it.')
72 if (readsSecretFile(w)) return deny('Guardrails Lite: this command would print or copy a secret .env file. Use .env.example instead.')
73 }
74 }
75 return null
76}
77