Asks the user before Edit, Write, NotebookEdit or simple Bash redirections/mv/cp/rm touch a path outside the session folder

Asks for confirmation before Claude writes outside the session folder.
Edit, Write, NotebookEdit (file_path / notebook_path) and simple Bash writes; a path outside the fence turns the verdict into an ask naming the path. A call already denied stays denied.>, >>, &> redirections, tee, mv, cp, rm, rmdir, touch, mkdir, install, ln. /dev/... targets and paths containing $ or backticks (other than a leading $HOME) are ignored.~/.claude, /tmp, /private/tmp, /var/folders, /private/var/folders, plus the allowedRoots option. ~, . and .. are resolved; a path that cannot be anchored counts as outside.| Command | Effect |
|---|---|
/path-fence status | Show whether it is on and the current fence (default when no argument) |
/path-fence off | Disable for this session (status line shows path-fence: OFF) |
/path-fence on | Re-enable |
| Field | Type | Default | Meaning |
|---|---|---|---|
allowedRoots | string | "" | Comma-separated absolute folders (or ~/...) where writes never ask |
claude --plugin-dir /path/to/ModsTools/mods/path-fence
sed -i, dd of=, curl -o, tar -C, git writes, scripts or editors that write by themselves, command substitutions, variables other than $HOME, symlinks (paths are compared as written).auto / bypassPermissions mode the mode settles the question.claude plugin validate mods/path-fence
claude plugin test mods/path-fence # 72 testshooks/register.ts 69 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { bashOutside, fileOutside, parseRoots } from './rules'
5import type { Fence } from './rules'
6
7const isOff = atom({ plugin: 'path-fence', key: 'isOff' } as const, false)
8
9let extraRoots: string[] = []
10
11async function fence($: EngineInterface): Promise<Fence> {
12 return { cwd: await $.session.cwd(), home: await $.env.get('HOME'), extraRoots }
13}
14
15async function escalate<T extends { decision: string }>(
16 $: EngineInterface,
17 verdict: T,
18 find: (f: Fence) => string | null,
19): Promise<T | { decision: 'ask'; reason: string }> {
20 if (verdict.decision === 'deny' || (await read($, isOff))) return verdict
21 const outside = find(await fence($))
22 if (outside === null) return verdict
23
24 return {
25 decision: 'ask',
26 reason: `path-fence: this writes outside the session folder (${outside}). Confirm only if the user expects it. If refused, ask the user: they can allow it with "! <command>" or stop the checks with /path-fence off.`,
27 }
28}
29
30export const register: Register = (on, options) => {
31 extraRoots = parseRoots(options?.allowedRoots)
32
33 on('session.start', async ($, e, next) => {
34 await $.command.register({
35 name: 'path-fence',
36 description: 'path-fence on|off|status: ask before writing outside the session folder',
37 })
38
39 return next(e)
40 })
41
42 on('command.run', { command: 'path-fence' }, async ($, e) => {
43 const arg = e.args.trim()
44 if (arg === 'off' || arg === 'on') {
45 await update($, isOff, () => arg === 'off')
46 $.ui.status(arg === 'off' ? 'path-fence: OFF' : undefined)
47 }
48 const off = await read($, isOff)
49 const f = await fence($)
50
51 return { text: `path-fence is ${off ? 'OFF for this session' : 'ON'}. Fence: ${f.cwd}${extraRoots.length > 0 ? `, ${extraRoots.join(', ')}` : ''} (plus ~/.claude and tmp dirs).` }
52 })
53
54 for (const tool of ['Edit', 'Write', 'NotebookEdit'] as const) {
55 on('tool.check', { tool }, async ($, e, next) => {
56 const verdict = await next(e)
57
58 return escalate($, verdict, f => fileOutside((e.input ?? {}) as Record<string, unknown>, f))
59 })
60 }
61
62 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
63 const verdict = await next(e)
64 const command = (e.input as { command?: unknown }).command
65
66 return typeof command === 'string' ? escalate($, verdict, f => bashOutside(command, f)) : verdict
67 })
68}
69hooks/rules.ts 129 lines1// Word-level parsing, like rm-guard. Bash coverage is limited to `>`/`>>`/`&>` redirections, `tee`,
2// `mv`, `cp`, `rm`, `touch`, `mkdir`, `install`, `ln`. Not covered: `sed -i`, `dd of=`, `curl -o`, `tar -C`,
3// `git` writes, scripts or editors that write by themselves, variables other than $HOME, command
4// substitutions, and symlinks (paths are compared as written, `..` and `~` resolved).
5
6export type Fence = { cwd: string; home: string | undefined; extraRoots: readonly string[] }
7
8const FIXED_ROOTS = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
9
10/** Absolute, `~` expanded, `.`/`..`/`//` resolved. Null when a relative path cannot be anchored. */
11export function normalize(path: string, cwd: string, home: string | undefined): string | null {
12 let p = path
13 if (p === '~' || p.startsWith('~/')) {
14 if (home === undefined) return null
15 p = home + p.slice(1)
16 }
17 if (!p.startsWith('/')) p = `${cwd}/${p}`
18 const out: string[] = []
19 for (const part of p.split('/')) {
20 if (part === '' || part === '.') continue
21 if (part === '..') out.pop()
22 else out.push(part)
23 }
24
25 return `/${out.join('/')}`
26}
27
28const isUnder = (path: string, root: string) => root === '/' || path === root || path.startsWith(`${root}/`)
29
30/** The extra roots option: a comma-separated string or a list. */
31export function parseRoots(value: unknown): string[] {
32 const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []
33
34 return items.filter((v): v is string => typeof v === 'string').map(v => v.trim()).filter(Boolean)
35}
36
37/** True when the path is inside the session folder or an allowed root. An unresolvable path is outside. */
38export function isAllowed(path: string, fence: Fence): boolean {
39 const target = normalize(path, fence.cwd, fence.home)
40 if (target === null) return false
41 const roots = [fence.cwd, ...FIXED_ROOTS, ...parseRoots(fence.extraRoots)]
42 if (fence.home !== undefined) roots.push(`${fence.home}/.claude`)
43
44 return roots.some(root => {
45 const r = normalize(root, fence.cwd, fence.home)
46
47 return r !== null && isUnder(target, r)
48 })
49}
50
51const WRAPPERS: Record<string, Set<string>> = {
52 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
53 env: new Set(['-u', '-C', '-S']),
54 command: new Set(),
55 nohup: new Set(),
56 time: new Set(),
57 nice: new Set(['-n']),
58 rtk: new Set(),
59}
60
61const unquote = (word: string) => word.replace(/^['"]+|['"]+$/g, '')
62
63const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
64
65function strip(words: string[]): string[] {
66 let i = 0
67 for (;;) {
68 while (words[i] !== undefined && isAssignment(words[i]!)) i++
69 const takesArg = WRAPPERS[words[i] ?? '']
70 if (takesArg === undefined) break
71 i++
72 while (words[i]?.startsWith('-')) i += takesArg.has(words[i]!) ? 2 : 1
73 }
74
75 return words.slice(i)
76}
77
78const baseName = (word: string) => word.slice(word.lastIndexOf('/') + 1)
79
80// File-descriptor duplications (`2>&1`) are not files; `&>` is a redirection to a file.
81const prepare = (command: string) => command.replace(/\d*>&(\d+|-)/g, '').replace(/&>>?/g, '>')
82
83const operands = (args: string[]) => {
84 const plain: string[] = []
85 let isOptions = true
86 for (const arg of args) {
87 if (isOptions && arg === '--') isOptions = false
88 else if (!isOptions || !arg.startsWith('-')) plain.push(arg)
89 }
90
91 return plain
92}
93
94/** Paths a simple Bash command writes to, as written (see the header for what is not covered). */
95export function bashTargets(command: string): string[] {
96 const targets: string[] = []
97 for (const part of prepare(command).split(/&&|\|\||[;|&\n]/)) {
98 const raw = part.trim().split(/\s+/).filter(Boolean).map(unquote)
99 const words: string[] = []
100 for (let i = 0; i < raw.length; i++) {
101 const m = /^\d*>>?(.*)$/.exec(raw[i]!)
102 if (m === null) words.push(raw[i]!)
103 else if (m[1] !== '') targets.push(m[1]!)
104 else if (raw[i + 1] !== undefined) targets.push(raw[++i]!)
105 }
106 const [name, ...args] = strip(words)
107 const files = operands(args)
108 const base = baseName(name ?? '')
109 if (['rm', 'rmdir', 'touch', 'mkdir', 'tee', 'mv'].includes(base)) targets.push(...files)
110 else if (['cp', 'install', 'ln'].includes(base) && files.length > 0) targets.push(files.at(-1)!)
111 }
112
113 return targets.filter(t => t !== '' && !t.startsWith('/dev/') && !/[`$]/.test(t.replace(/^\$\{?HOME\}?/, '')))
114}
115
116const expandHome = (t: string) => t.replace(/^\$\{?HOME\}?(?=\/|$)/, '~')
117
118/** The first path outside the fence a Bash command writes to, or null. */
119export function bashOutside(command: string, fence: Fence): string | null {
120 return bashTargets(command).map(expandHome).find(t => !isAllowed(t, fence)) ?? null
121}
122
123/** The path an Edit/Write/NotebookEdit call touches, when it is outside the fence. */
124export function fileOutside(input: Record<string, unknown>, fence: Fence): string | null {
125 const path = input.file_path ?? input.notebook_path
126
127 return typeof path === 'string' && path !== '' && !isAllowed(path, fence) ? path : null
128}
129types/index.d.ts 8 lines1export type PathFenceSwitch = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'path-fence': { isOff: PathFenceSwitch }
6 }
7}
8