SLOPSHOPPER

protected-paths-guard

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

newguard
A shopper browsing a rack in a slop shop
README

protected-paths-guard

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.

Options

  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": { } } } }

Install

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

Source 1 files
hooks/protected-paths-guard.ts 93 lines
1/**
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