Denies Edit, Write and NotebookEdit calls on sensitive paths (.env files, lockfiles, CI workflows, git internals, private keys) with a glob allowlist override…

Denies Edit / Write / NotebookEdit calls that target sensitive files (.env, lockfiles, CI workflows, git internals, private keys) unless the path is allowlisted. A tool.call hook under an array matcher (any of the named tools); on a match it returns { deny } without calling next.
If the check itself fails (it throws or runs out of its time budget), the edit is denied, never let through unchecked.
protect: string extra globs to protect, comma-separated
allow: string globs that are always allowed (checked first), comma-separated
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:
{ "pluginConfigs": { "protected-paths-guard@skills-dir": { "options": { } } } }
npx claude-code-templates@latest --mod security/protected-paths-guard
claude
It is written to .claude/skills/protected-paths-guard/, which Claude Code auto-loads as protected-paths-guard@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/protected-paths-guard. claude plugin validate .claude/skills/protected-paths-guard prints every event it hooks and every $ call it makes.
Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/protected-paths-guard.ts 93 lines1/**
2 * protected-paths-guard — Claude Mod
3 *
4 * Denies Edit / Write / NotebookEdit calls that target sensitive files
5 * (.env, lockfiles, CI workflows, git internals, private keys) unless the path
6 * is allowlisted. A `tool.call` hook under an array matcher (any of the named
7 * tools); on a match it returns `{ deny }` without calling `next`.
8 *
9 * Needs Claude Code >= 2.1.287. Typed
10 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
11 *
12 * Options:
13 * protect: string extra globs to protect, comma-separated
14 * allow: string globs that are always allowed (checked first), comma-separated
15 */
16import type { Register } from 'claude-code'
17
18// Minimal glob support: "**" = any depth, "*" = any chars except "/". One pass over the glob, so
19// the regex a wildcard produces is never rewritten by a later replacement.
20function globToRegExp(glob: string): RegExp {
21 const source = glob.replace(/\*\*\/|\*\*|\*|[.+^${}()|[\]\\]/g, (m) =>
22 m === '**/' ? '(?:.*/)?' : m === '**' ? '.*' : m === '*' ? '[^/]*' : `\\${m}`,
23 )
24 return new RegExp(`(^|/)${source}$`)
25}
26
27const DEFAULT_PROTECTED: readonly string[] = [
28 '.env',
29 '.env.*',
30 '**/.git/**',
31 'package-lock.json',
32 'pnpm-lock.yaml',
33 'yarn.lock',
34 'Cargo.lock',
35 'poetry.lock',
36 '.github/workflows/*.yml',
37 '.github/workflows/*.yaml',
38 '**/*.pem',
39 '**/*.key',
40 '**/id_rsa*',
41]
42
43/** A list option: a string[] or a comma-separated string (what a manifest's `userConfig` string field holds); empty means unset. */
44function strings(value: unknown): string[] | undefined {
45 const list = Array.isArray(value)
46 ? value.filter((v): v is string => typeof v === 'string')
47 : typeof value === 'string'
48 ? value.split(',').map((s) => s.trim()).filter(Boolean)
49 : []
50 return list.length > 0 ? list : undefined
51}
52
53export const register: Register = (on, options) => {
54 const protectedGlobs = [...DEFAULT_PROTECTED, ...(strings(options.protect) ?? [])]
55 const allowGlobs = strings(options.allow) ?? []
56 const protectedRes = protectedGlobs.map(globToRegExp)
57 const allowRes = allowGlobs.map(globToRegExp)
58
59 // An array in a matcher matches when any element matches.
60 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, ($, e, next) => {
61 const rawPath = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
62 const filePath = rawPath.replace(/\\/g, '/')
63 if (!filePath) return next(e)
64
65 if (allowRes.some((re) => re.test(filePath))) return next(e)
66
67 const hit = protectedRes.findIndex((re) => re.test(filePath))
68 if (hit !== -1) {
69 const rule = protectedGlobs[hit]
70 $.ui.log(`[protected-paths-guard] denied ${e.tool} on ${filePath} (rule: ${rule})`)
71 return {
72 deny:
73 `${filePath} is protected by protected-paths-guard (rule "${rule}"). ` +
74 'Ask the user to edit it manually or add the path to the plugin\'s "allow" option.',
75 }
76 }
77
78 return next(e)
79 }).catch(async ($, e, next) => {
80 // The check had passed and the write ran: hand back its result (replayed, nothing runs twice).
81 if (next.called) {
82 try {
83 return await next(e)
84 } catch {
85 return { deny: `The ${e.tool} on ${e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path} failed.` }
86 }
87 }
88 // A failed check never lets the write through unchecked (fail closed).
89 $.ui.log(`[protected-paths-guard] check failed (${next.error.kind}); denied ${e.tool}`)
90 return { deny: `protected-paths-guard could not check this path (${next.error.kind}), so the ${e.tool} was not run. Ask the user to make the change manually.` }
91 })
92}
93