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…

Keeps Claude's file edits inside the project. Writes outside it are refused (symlinks resolved),
.gitinternals 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.
/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./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..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./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.fenceReads to fence them too./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.
Set these in /config, or under pluginConfigs in settings.json.
| Option | Default | What it does |
|---|---|---|
protect | workflows, 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. |
extraRoots | empty | Comma-separated folders outside the project that Claude may write, e.g. ~/notes, ~/work/shared-config. |
allowTemp | true | Allow /tmp, /var/tmp and $TMPDIR. |
allowClaudeDirs | true | Allow ~/.claude/projects, plans, todos and dev-mods. |
fenceReads | false | Also refuse Read outside the fence. |
| Event / API | Why |
|---|---|
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 |
$.store | The all-time blocked count |
Path logic and the glob matcher (hooks/paths.ts, hooks/verdict.ts) are pure TypeScript with no $.
claude plugin test mods/safety/file-guard # 48 tests
echo x > ~/.zshrc in Bash is bash-guard's job, and a full sandbox's.hooks/register.ts 193 lines1import 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}
193hooks/paths.ts 110 lines1// 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}
110hooks/verdict.ts 70 lines1// 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