SLOPSHOPPER

file-guard

Keeps Claude's file edits inside the project: refuses writes outside it (symlinks resolved), never lets a tool touch .git internals, and asks before it changes…

newguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-guard
› 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(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /file-guard ⎿ file-guard: Project: /work/app ⎿ file-guard: Also writable: nothing else ⎿ file-guard: Reads outside the project: allowed ⎿ file-guard: Asks before changing: .github/workflows/**, **/migrations/**, **/*.lock, package-lock.json, pnpm-lock.yaml, ya ⎿ file-guard: This session: 0 blocked, 0 allowed after you confirmed. All time: 0 blocked. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🚧 file-guard

Keeps Claude's file edits inside the project. Writes outside it are refused (symlinks resolved), .git internals are off limits, and lockfiles, CI workflows, migrations and secrets ask first.

● Write(/Users/me/.zshrc)
  ⎿  Error: file-guard refused to change this file: ~/.zshrc is outside this
     project (~/code/shop). Keep your work inside the project. If this file
     really has to be touched, tell the user why and let them do it, or ask
     them to add the folder to file-guard's extraRoots option.

╭─ file-guard ─────────────────────────────────────────────────╮
│ file-guard: .github/workflows/deploy.yml is protected        │
│ (matches .github/workflows/**).                              │
│                                                              │
│   Edit .github/workflows/deploy.yml                          │
│                                                              │
│ Allow this change?                                           │
│ ❯ 1. Allow once                                              │
│   2. Allow this file for the session                         │
│   3. Block it                                                │
╰──────────────────────────────────────────────────────────────╯

An agent that can edit any file you can is one bad path away from your dotfiles, another repo, or ~/.claude/settings.json. file-guard checks every Read, Edit, Write and NotebookEdit call, including subagents' calls, below the permission system, so it holds in bypass mode too.

Features

  • The fence. A write must land inside the project root (the folder the session started in, or where /cd moved it), or in a folder you allow. Every path is resolved first: .., relative paths and symlinks. So src/../../etc/hosts and a link pointing out of the repo are caught, and files that don't exist yet are placed through their nearest existing folder.
  • Sensible exceptions. /tmp, /var/tmp and $TMPDIR are writable, and so are Claude Code's own memory, plan, todo and session-mod folders under ~/.claude. ~/.claude/settings.json is not.
  • .git is off limits. No tool writes into .git/ (hooks, config, refs). Git commands still work.
  • Protected files ask first: CI workflows, migrations, lockfiles, .env* (but not .env.example), keys. You can allow a change once or for the whole session. With nobody to ask (-p), the change is refused.
  • Deny messages tell the model what to do instead, so it stops and asks you rather than hunting for a workaround.
  • /file-guard shows the fence, the protected patterns and what was blocked. /file-guard check <path> dry-runs any path and shows where it really lands.
  • Reads outside the project are allowed by default. Turn on fenceReads to fence them too.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install file-guard@awesome-claude-mods

Requires Claude Code 2.1.287 or later. Pairs well with bash-guard, which covers the shell.

Configuration

Set these in /config, or under pluginConfigs in settings.json.

OptionDefaultWhat it does
protectworkflows, migrations, lockfiles, .env*, keys, .git/**Comma-separated globs inside the project that ask before a write. ** crosses folders, * and ? stay in one, a pattern without / matches the file name anywhere, and !glob exempts.
extraRootsemptyComma-separated folders outside the project that Claude may write, e.g. ~/notes, ~/work/shared-config.
allowTemptrueAllow /tmp, /var/tmp and $TMPDIR.
allowClaudeDirstrueAllow ~/.claude/projects, plans, todos and dev-mods.
fenceReadsfalseAlso refuse Read outside the fence.

How it works

Event / APIWhy
tool.call (Read, Edit, Write, NotebookEdit, MultiEdit)Resolves the target, judges it, and returns { deny }, asks with $.ui.ask, or runs next(e)
$.fs.stat(path, { resolve: true })Where a path really lands, links followed. This is the allow-list-on-realPath pattern the mods API recommends.
$.session.root(), $.session.cwd(), $.env.get('HOME' / 'TMPDIR')The fence and its exceptions
command.run/file-guard status and dry-run
$.storeThe all-time blocked count

Path logic and the glob matcher (hooks/paths.ts, hooks/verdict.ts) are pure TypeScript with no $.

Test it

claude plugin test mods/safety/file-guard   # 48 tests

Limitations

  • It guards Claude Code's file tools, not the shell. echo x > ~/.zshrc in Bash is bash-guard's job, and a full sandbox's.
  • Paths are POSIX (macOS, Linux, WSL). Windows drive paths aren't fenced.
  • A hard link inside the project that points at a file outside it keeps its own spelling, so it can't be detected. Symlinks are caught.
Source 3 files
hooks/register.ts 193 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { basename, dirname, join, normalize, parseList, relative, resolveSpelling } from './paths'
4import { denyText, display, judge, type Access, type Fence, type Verdict } from './verdict'
5
6export const DEFAULT_PROTECT =
7  '.github/workflows/**, **/migrations/**, **/*.lock, package-lock.json, pnpm-lock.yaml, yarn.lock, Cargo.lock, ' +
8  'poetry.lock, **/.env*, !**/.env.example, !**/.env.sample, !**/.env.template, **/*.pem, **/id_rsa*, .git/**'
9
10// Claude Code's own folders it writes on purpose: memory, plans, todos, mods made in a session.
11const CLAUDE_DIRS = ['~/.claude/projects', '~/.claude/plans', '~/.claude/todos', '~/.claude/dev-mods']
12const TEMP_DIRS = ['/tmp', '/private/tmp', '/var/tmp']
13
14const ONCE = 'Allow once'
15const SESSION = 'Allow this file for the session'
16const BLOCK = 'Block it'
17
18const ACCESS: Record<string, Access> = {
19  Read: 'read',
20  Edit: 'write',
21  MultiEdit: 'write',
22  Write: 'write',
23  NotebookEdit: 'write',
24}
25
26type Config = {
27  extraRoots: string[]
28  allowTemp: boolean
29  allowClaudeDirs: boolean
30  protect: string[]
31  fenceReads: boolean
32}
33
34// Session tallies and caches; a reload starts them over.
35let blockedCount = 0
36let confirmedCount = 0
37const allowedThisSession = new Set<string>()
38const placedCache = new Map<string, string | undefined>()
39
40/** The path a tool argument names, if it names one. */
41function targetOf(input: unknown): string | undefined {
42  const record = input as Record<string, unknown>
43  const path = record.file_path ?? record.notebook_path
44  return typeof path === 'string' && path !== '' ? path : undefined
45}
46
47/**
48 * Where an absolute path really lands, every link resolved, even when the file
49 * (or its folders) does not exist yet: the nearest existing ancestor is
50 * resolved and the rest appended. Undefined when it leads nowhere.
51 */
52async function placeReal($: EngineInterface, absolute: string): Promise<string | undefined> {
53  let probe = normalize(absolute)
54  const rest: string[] = []
55  for (let depth = 0; depth < 128; depth++) {
56    const stat = await $.fs.stat(probe, { resolve: true }).catch(() => undefined)
57    if (stat !== undefined) {
58      if (stat.realPath === undefined) return undefined
59      return rest.length === 0 ? normalize(stat.realPath) : join(stat.realPath, ...rest.reverse())
60    }
61    if (probe === '/') return undefined
62    rest.push(basename(probe))
63    probe = dirname(probe)
64  }
65  return undefined
66}
67
68async function placeCached($: EngineInterface, spelling: string): Promise<string | undefined> {
69  if (placedCache.has(spelling)) return placedCache.get(spelling)
70  const real = await placeReal($, spelling)
71  placedCache.set(spelling, real)
72  return real
73}
74
75async function buildFence($: EngineInterface, config: Config, home: string | undefined): Promise<Fence> {
76  const cwd = await $.session.cwd()
77  const root = (await placeCached($, normalize(await $.session.root()))) ?? normalize(await $.session.root())
78  const spellings = [...config.extraRoots]
79  if (config.allowTemp) {
80    spellings.push(...TEMP_DIRS)
81    const tmp = await $.env.get('TMPDIR')
82    if (tmp !== undefined && tmp !== '') spellings.push(tmp)
83  }
84  if (config.allowClaudeDirs) spellings.push(...CLAUDE_DIRS)
85  const allowed: string[] = []
86  for (const spelling of spellings) {
87    if (spelling.startsWith('~') && home === undefined) continue
88    const real = await placeCached($, resolveSpelling(spelling, home, cwd))
89    if (real !== undefined && real !== '/') allowed.push(real)
90  }
91  return { root, allowed, protect: config.protect, fenceReads: config.fenceReads }
92}
93
94async function refuseFileCall($: EngineInterface, verdict: Exclude<Verdict, { kind: 'allow' }>, access: Access, tool: string) {
95  blockedCount += 1
96  const total = Number((await $.store.get('blockedTotal')) ?? 0) + 1
97  await $.store.set('blockedTotal', total)
98  $.ui.toast(`file-guard blocked ${tool}: ${verdict.why}`)
99  return { deny: denyText(verdict, access) }
100}
101
102export const register: Register = (on, options) => {
103  const config: Config = {
104    extraRoots: parseList(String(options.extraRoots ?? '')),
105    allowTemp: options.allowTemp !== false,
106    allowClaudeDirs: options.allowClaudeDirs !== false,
107    protect: parseList(String(options.protect ?? DEFAULT_PROTECT)),
108    fenceReads: options.fenceReads === true,
109  }
110
111  on('session.start', async ($, e, next) => {
112    placedCache.clear()
113    try {
114      await $.command.register({
115        name: 'file-guard',
116        description: 'Show the fence and what file-guard blocked, or test a path: /file-guard check <path>',
117        argumentHint: '[check <path>]',
118      })
119    } catch {
120      // The guard works without its command.
121    }
122    return next(e)
123  })
124
125  on('tool.call', async ($, e, next) => {
126    const tool = String(e.tool)
127    const access = ACCESS[tool]
128    const spelled = access === undefined ? undefined : targetOf(e)
129    if (access === undefined || spelled === undefined) return next(e)
130
131    const home = await $.env.get('HOME')
132    const absolute = resolveSpelling(spelled, home, await $.session.cwd())
133    const real = await placeReal($, absolute)
134    const fence = await buildFence($, config, home)
135    const verdict = judge(real, access, fence, home)
136
137    if (verdict.kind === 'allow') return next(e)
138    if (verdict.kind === 'block') return refuseFileCall($, verdict, access, tool)
139
140    if (real !== undefined && allowedThisSession.has(real)) return next(e)
141    let answer = BLOCK
142    try {
143      answer = await $.ui.ask(`file-guard: ${verdict.why}.\n\n  ${tool} ${relative(real ?? absolute, fence.root)}\n\nAllow this change?`, {
144        header: 'file-guard',
145        options: [ONCE, SESSION, BLOCK],
146      })
147    } catch {
148      // Nobody to ask (a -p run) or the dialog was dismissed: stay safe.
149    }
150    if (answer === ONCE || answer === SESSION) {
151      confirmedCount += 1
152      if (answer === SESSION && real !== undefined) allowedThisSession.add(real)
153      return next(e)
154    }
155    return refuseFileCall($, verdict, access, tool)
156  })
157
158  on('command.run', { command: 'file-guard' }, async ($, e) => {
159    const home = await $.env.get('HOME')
160    const fence = await buildFence($, config, home)
161    const [sub, ...rest] = e.args.trim().split(/\s+/)
162
163    if (sub === 'check') {
164      const spelled = rest.join(' ')
165      if (spelled === '') return { text: 'Usage: /file-guard check <path>' }
166      const absolute = resolveSpelling(spelled, home, await $.session.cwd())
167      const real = await placeReal($, absolute)
168      const write = judge(real, 'write', fence, home)
169      const read = judge(real, 'read', fence, home)
170      const shown = (v: Verdict) => (v.kind === 'allow' ? 'allowed' : v.kind === 'block' ? `blocked: ${v.why}` : `asks first: ${v.why}`)
171      return {
172        text: [
173          `${display(absolute, home)}${real !== undefined && real !== absolute ? ` → ${display(real, home)}` : ''}`,
174          `  write: ${shown(write)}`,
175          `  read:  ${shown(read)}`,
176        ].join('\n'),
177      }
178    }
179
180    const total = Number((await $.store.get('blockedTotal')) ?? 0)
181    const allowed = fence.allowed.map(dir => display(dir, home))
182    return {
183      text: [
184        `Project: ${display(fence.root, home)}`,
185        `Also writable: ${allowed.length > 0 ? allowed.join(', ') : 'nothing else'}`,
186        `Reads outside the project: ${fence.fenceReads ? 'blocked' : 'allowed'}`,
187        `Asks before changing: ${fence.protect.join(', ') || 'nothing'}`,
188        `This session: ${blockedCount} blocked, ${confirmedCount} allowed after you confirmed. All time: ${total} blocked.`,
189      ].join('\n'),
190    }
191  })
192}
193
hooks/paths.ts 110 lines
1// POSIX path helpers and a small glob matcher. Pure: no `$`.
2
3/** Folds `.`, `..` and repeated slashes; keeps a path absolute if it was. */
4export function normalize(path: string): string {
5  const isAbsolute = path.startsWith('/')
6  const out: string[] = []
7  for (const part of path.split('/')) {
8    if (part === '' || part === '.') continue
9    if (part === '..') {
10      if (out.length > 0 && out[out.length - 1] !== '..') out.pop()
11      else if (!isAbsolute) out.push('..')
12      continue
13    }
14    out.push(part)
15  }
16  const joined = out.join('/')
17  return isAbsolute ? `/${joined}` : joined || '.'
18}
19
20export function join(...parts: string[]): string {
21  return normalize(parts.filter(p => p !== '').join('/'))
22}
23
24export function dirname(path: string): string {
25  const p = normalize(path)
26  if (p === '/') return '/'
27  const cut = p.lastIndexOf('/')
28  if (cut < 0) return '.'
29  return cut === 0 ? '/' : p.slice(0, cut)
30}
31
32export function basename(path: string): string {
33  const p = normalize(path)
34  return p === '/' ? '' : p.slice(p.lastIndexOf('/') + 1)
35}
36
37/** Whether `child` is `parent` or lies beneath it (both normalized, absolute). */
38export function isInside(child: string, parent: string): boolean {
39  if (parent === '/') return child.startsWith('/')
40  return child === parent || child.startsWith(`${parent}/`)
41}
42
43/** `child` relative to `parent`, which must contain it. */
44export function relative(child: string, parent: string): string {
45  if (child === parent) return ''
46  return parent === '/' ? child.slice(1) : child.slice(parent.length + 1)
47}
48
49/** `~` and `~/x` under `home`; an absolute path as is; anything else under `cwd`. */
50export function resolveSpelling(path: string, home: string | undefined, cwd: string): string {
51  const trimmed = path.trim()
52  if ((trimmed === '~' || trimmed.startsWith('~/')) && home !== undefined) return join(home, trimmed.slice(1))
53  return trimmed.startsWith('/') ? normalize(trimmed) : join(cwd, trimmed)
54}
55
56/** Splits a comma-separated option into trimmed, non-empty entries. */
57export function parseList(value: string): string[] {
58  return value
59    .split(',')
60    .map(item => item.trim())
61    .filter(item => item !== '')
62}
63
64/** Whether any segment of the path is a `.git` directory (not `.github` or `.gitignore`). */
65export function isGitInternal(path: string): boolean {
66  return normalize(path).split('/').includes('.git')
67}
68
69/**
70 * Turns a glob into an anchored regular expression: `**` crosses folders,
71 * `*` and `?` stay inside one. A pattern without a slash matches the file name
72 * at any depth, as in .gitignore.
73 */
74export function globToRegExp(glob: string): RegExp {
75  let source = ''
76  for (let i = 0; i < glob.length; ) {
77    if (glob.startsWith('**/', i)) {
78      source += '(?:.*/)?'
79      i += 3
80    } else if (glob.startsWith('/**', i) && i + 3 === glob.length) {
81      source += '(?:/.*)?'
82      i += 3
83    } else if (glob.startsWith('**', i)) {
84      source += '.*'
85      i += 2
86    } else {
87      const ch = glob[i]!
88      source += ch === '*' ? '[^/]*' : ch === '?' ? '[^/]' : ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
89      i += 1
90    }
91  }
92  return new RegExp(`^${source}$`)
93}
94
95export function matchesGlob(relativePath: string, glob: string): boolean {
96  const pattern = glob.replace(/^\.\//, '')
97  const target = pattern.includes('/') ? relativePath : basename(relativePath)
98  return globToRegExp(pattern).test(target)
99}
100
101/**
102 * The first positive pattern that matches, unless a `!pattern` also does.
103 * Undefined when the path is not protected.
104 */
105export function protectedBy(relativePath: string, patterns: readonly string[]): string | undefined {
106  const isExcluded = patterns.some(p => p.startsWith('!') && matchesGlob(relativePath, p.slice(1)))
107  if (isExcluded) return undefined
108  return patterns.find(p => !p.startsWith('!') && matchesGlob(relativePath, p))
109}
110
hooks/verdict.ts 70 lines
1// What file-guard decides for one resolved path. Pure: no `$`.
2
3import { isGitInternal, isInside, protectedBy, relative } from './paths'
4
5export type Access = 'read' | 'write'
6
7export type Fence = {
8  /** The project root, every link resolved. */
9  root: string
10  /** Other places writes may go (extra roots, temp, Claude's own folders), resolved. */
11  allowed: readonly string[]
12  /** Globs inside the project that ask before a write; `!glob` exempts. */
13  protect: readonly string[]
14  /** Deny reads outside the fence too. */
15  fenceReads: boolean
16}
17
18export type Verdict =
19  | { kind: 'allow' }
20  | { kind: 'block'; rule: 'unplaceable' | 'git' | 'outside'; why: string }
21  | { kind: 'confirm'; rule: 'protected'; pattern: string; why: string }
22
23/** `~/x` for a path under `home`, else the path. */
24export function display(path: string, home: string | undefined): string {
25  if (home !== undefined && home !== '/' && isInside(path, home)) return path === home ? '~' : `~/${relative(path, home)}`
26  return path
27}
28
29export function judge(real: string | undefined, access: Access, fence: Fence, home?: string): Verdict {
30  if (real === undefined) {
31    if (access === 'read' && !fence.fenceReads) return { kind: 'allow' }
32    return { kind: 'block', rule: 'unplaceable', why: 'file-guard cannot tell where this path really lands (a dangling link or an unusual spelling)' }
33  }
34  if (access === 'write' && isGitInternal(real)) {
35    return { kind: 'block', rule: 'git', why: `${display(real, home)} is inside git's own .git folder, which file-guard never lets a tool write` }
36  }
37  if (isInside(real, fence.root)) {
38    if (access === 'read') return { kind: 'allow' }
39    const pattern = protectedBy(relative(real, fence.root), fence.protect)
40    if (pattern === undefined) return { kind: 'allow' }
41    return { kind: 'confirm', rule: 'protected', pattern, why: `${relative(real, fence.root)} is protected (matches ${pattern})` }
42  }
43  if (fence.allowed.some(dir => isInside(real, dir))) return { kind: 'allow' }
44  if (access === 'read' && !fence.fenceReads) return { kind: 'allow' }
45  return {
46    kind: 'block',
47    rule: 'outside',
48    why: `${display(real, home)} is outside this project (${display(fence.root, home)})`,
49  }
50}
51
52/** The text the model reads when a call is refused: what happened and what to do instead. */
53export function denyText(verdict: Exclude<Verdict, { kind: 'allow' }>, access: Access): string {
54  const verb = access === 'read' ? 'read' : 'change'
55  switch (verdict.rule) {
56    case 'outside':
57      return (
58        `file-guard refused to ${verb} this file: ${verdict.why}. ` +
59        'Keep your work inside the project. If this file really has to be touched, tell the user why and let them do it, ' +
60        "or ask them to add the folder to file-guard's extraRoots option."
61      )
62    case 'git':
63      return `file-guard refused: ${verdict.why}. Use git commands instead of editing .git directly.`
64    case 'unplaceable':
65      return `file-guard refused to ${verb} this file: ${verdict.why}. Use a plain absolute path inside the project.`
66    case 'protected':
67      return `file-guard: changing this file needs the user's OK, because ${verdict.why}, and they didn't give it. Don't retry; ask the user how they want this file changed.`
68  }
69}
70