Limit which paths Claude may edit in a project with one /fence command. Edits outside the fence are refused with a reason Claude can act on.

Limit which paths Claude may edit in a project with one command. When Claude tries to edit, write, or change a notebook outside the fence, the call is refused before it runs and Claude reads why, so it explains instead of quietly touching files you didn't ask about.
Use it when you say "only touch src/billing" and want that enforced, not just hoped for.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install fence@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/fence
| Command | What it does |
|---|---|
/fence src/ docs/README.md | Allow edits only under those paths. Relative paths resolve from the project root. |
/fence | Show the current fence. |
/fence off | Remove it. |
/fence on, /fence status | Show the current fence and how to set one. Words like these are never taken as paths. |
The fence is saved per project root, so it's still there after you restart Claude Code. It runs immediately, even while Claude is working, so you can tighten it mid-turn.
When an edit is refused, a dim line in the transcript names the file, and Claude receives:
fence: /work/package.json is outside the paths the user allowed for this project (/work/src). Do not edit it. If the change is required, explain why and ask the user to run /fence to widen the fence.
From claude plugin validate ./mods/fence:
hooks: session.start, command.run{command=fence}, tool.call{tool=Edit|Write|NotebookEdit}
calls: $.command.register, $.session.root, $.store.delete, $.store.get, $.store.set, $.ui.log
No environment variables, processes, or network. State lives in Claude Code's plugin store under a fence:<project root> key.
claude plugin validate --strict and claude plugin test pass.sed -i or echo > file isn't checked. Pair it with a permission rule or a Bash guard if you need that.. and .. segments resolved, relative paths joined to the project root) and then compared as strings. Symlinks are not resolved, so a link inside the fence that points outside it is not caught. Use a permission deny rule for paths that must never change.tool.call fires for them. A subagent can't widen the fence; only /fence can.deny permission rule is the hard block.MIT, see the repository root.
hooks/register.ts 114 lines1// fence: limit which paths Claude may edit in this project.
2//
3// /fence src/ docs/README.md allow edits only under those paths
4// /fence show the current fence
5// /fence off remove it
6//
7// The fence is saved per project root in $.store, so it survives restarts.
8// Edit, Write, and NotebookEdit calls outside the fence are refused with a
9// reason Claude reads as the tool's result. The guard fails closed: if it
10// can't check a path, the edit doesn't happen.
11
12const USAGE = 'No fence set. Usage: /fence src/ docs/README.md (or /fence off)'
13// Words people type expecting a switch. Treating one as a path would fence the
14// project to a file that doesn't exist, so they get the usage line instead.
15const NOT_PATHS = ['on', 'enable', 'enabled', 'set', 'show', 'status', 'list', 'help', '?']
16
17// Resolve a path to an absolute, normalized form: no trailing slash, and no
18// "." or ".." segments, so "src/../package.json" compares as "package.json".
19function normalize(root: string, raw: string): string {
20 const absolute = raw.startsWith('/') ? raw : root + '/' + raw
21 const out: string[] = []
22 for (const part of absolute.split('/')) {
23 if (part === '' || part === '.') continue
24 if (part === '..') out.pop()
25 else out.push(part)
26 }
27 return '/' + out.join('/')
28}
29
30// Turn what the user typed into an absolute path under the project root.
31function resolveAllowed(root: string, typed: string): string | null {
32 const path = typed.trim()
33 return path ? normalize(root, path) : null
34}
35
36function isInside(root: string, file: string, allowed: string[]): boolean {
37 const path = normalize(root, file)
38 return allowed.some((dir) => path === dir || path.startsWith(dir === '/' ? '/' : dir + '/'))
39}
40
41function describe(allowed: unknown): string[] {
42 return Array.isArray(allowed) ? allowed.filter((p) => typeof p === 'string') : []
43}
44
45// The guard. Declared at the top level so static analysis can see its $ calls.
46async function guard($, e, next) {
47 const root = await $.session.root()
48 const allowed = describe(await $.store.get('fence:' + root))
49 if (allowed.length === 0) return next(e)
50
51 const file = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
52 if (typeof file !== 'string' || isInside(root, file, allowed)) return next(e)
53
54 $.ui.log('refused an edit outside the fence: ' + file)
55 return {
56 deny:
57 'fence: ' + file + ' is outside the paths the user allowed for this project (' +
58 allowed.join(', ') + '). Do not edit it. If the change is required, explain why ' +
59 'and ask the user to run /fence to widen the fence.',
60 }
61}
62
63export function register(on) {
64 on('session.start', async ($, e, next) => {
65 try {
66 await $.command.register({
67 name: 'fence',
68 description: 'Limit which paths Claude may edit in this project',
69 argumentHint: '[paths... | off]',
70 immediate: true,
71 })
72 } catch (error) {
73 $.ui.log('could not register /fence: ' + error)
74 }
75 return next(e)
76 })
77
78 on('command.run', { command: 'fence' }, async ($, e) => {
79 const root = await $.session.root()
80 const key = 'fence:' + root
81 const args = e.args.trim()
82
83 if (args === 'off') {
84 await $.store.delete(key)
85 return { text: 'Fence removed. Claude may edit any file.' }
86 }
87 if (NOT_PATHS.includes(args.toLowerCase())) {
88 const current = describe(await $.store.get(key))
89 return { text: (current.length ? 'Fence: ' + current.join(', ') + '. ' : '') + 'To set a fence, name the paths: /fence src/ docs/README.md. To remove it: /fence off.' }
90 }
91 if (!args) {
92 const current = describe(await $.store.get(key))
93 return { text: current.length ? 'Fence: ' + current.join(', ') : USAGE }
94 }
95 const allowed = args
96 .split(/\s+/)
97 .map((typed) => resolveAllowed(root, typed))
98 .filter((path): path is string => path !== null)
99 await $.store.set(key, allowed)
100 return { text: 'Fence set. Claude may edit only: ' + allowed.join(', ') }
101 }).catch(async () => ({ text: 'fence: the command failed, so nothing changed.' }))
102
103 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, guard).catch(async ($, e, next) => {
104 // The guard had already let the call through: return what came back.
105 if (next.called) return next(e)
106 // The guard failed before deciding: fail closed.
107 return {
108 deny:
109 'fence: could not check this path (' + next.error.kind + '), so the edit was not made. ' +
110 'Ask the user to run /fence off or try again.',
111 }
112 })
113}
114