Example: denies Edit and Write calls on a list of protected paths, and fails closed

A fail-closed guard. A tool.call hook on Edit and Write denies any call whose file_path matches a protected path, before next runs, so the tool never starts. A .catch handler checks next.called: if the hook throws before deciding, the call is denied instead of going through (chapter: mods-doc-mods-and-events, "Handle a hook that fails"; the matcher with a tool list is shown in "Guard or change a tool call").
Path matching is pure logic in hooks/logic.ts.
. segments (./a is a), .. segments, trailing slashes.~, globs, Windows drive letters. It never makes a relative path absolute, because it does not know the working directory. Letter case is not folded, so on a case-insensitive disk /P/.ENV passes a /p/.env entry... (for example ../x or a/../../x): a target path is absolute, so such an entry can never match, and storing it would claim a protection that is not there. /protect-files-paths add ../x answers ../x cannot be protected: a relative path that starts with .. never matches a file path and leaves the stored list as it is.C:/work/a.ts does not start with /, so it counts as relative. While any absolute entry exists, every Edit and Write with such a target is therefore denied./ protects everything. A relative entry protects the same whole segments wherever they appear in the target path. Matching is by whole segments, so src does not protect src2/a.ts.file_path, or a stored list that is not a list of strings, is refused. So is a relative file_path while any absolute entry exists, because it cannot be compared with it... (for example ../x): such an entry can never match.Edit and Write is refused, and every /protect-files-paths subcommand except reset answers that the list is damaged and leaves it as it is./protect-files-paths reset clears the list. It removes every protected path, valid ones included, so add back the ones you need. Both refusal texts say so.$.store.delete (mods-doc-mods-interfaces chapter.md:192). The store is this plugin's own (mods-doc-mods-api chapter.md:280), so no other mod can clear it./protect-files off lets calls through meanwhile./.. is not a damaged entry: it normalises to /, which protects everything./protect-files-paths list only shows the list and never writes it back.MultiEdit, NotebookEdit, and Bash (a shell command can still write a protected file). Add them to the matcher if your build names them as tool names.Off is a deliberate policy: /protect-files off lets every call through untouched. Default is on; a missing protect-files.enabled key means enabled. The protected list is stored under protect-files.paths and starts empty.
| Command | What it does | |
|---|---|---|
/protect-files | Toggle the guard. Answers with a toast. | |
/protect-files on / off | Set the guard. | |
/protect-files <anything else> | Answers `usage: /protect-files [on\ | off]`. |
/protect-files-paths add <path> | Add a path (stored normalised). | |
/protect-files-paths remove <path> | Remove a path. | |
/protect-files-paths list | Show the list. | |
/protect-files-paths reset | Delete the stored list, valid entries included (also when it is damaged). |
Store keys: protect-files.enabled (boolean), protect-files.paths (string array).
Constants exported from hooks/logic.ts:
| Constant | Value | Meaning | |||
|---|---|---|---|---|---|
ENABLED_KEY | 'protect-files.enabled' | The $.store key of the switch; a missing key means on | |||
PATHS_KEY | 'protect-files.paths' | The $.store key of the protected list; a missing key means empty | |||
SWITCH_USAGE | `'usage: /protect-files [on\ | off]'` | The answer to an unknown switch argument | ||
PATHS_USAGE | `'usage: /protect-files-paths add <path> \ | remove <path> \ | list \ | reset'` | The answer to an unknown paths argument |
DAMAGED_LIST_TEXT | the damaged-list message | The answer of /protect-files-paths when the stored list is not a string array or holds a relative entry that starts with ..; it tells the user to run /protect-files-paths reset, which removes every protected path (or /protect-files off to continue without the guard) |
$.store, every hook returns next(e) untouched when off.hooks/logic.ts, thin hooks/register.ts..catch that checks next.called.claude plugin test runs tests/logic.test.ts (pure functions) and tests/register.test.ts (kit: stubs the tool and $.store beneath the plugin, asserts the deny and that the tool stub did not run, that an unprotected path reaches the stub, that a relative target is denied, that a throwing hook is denied, that a damaged stored list is reported and left alone, and that session.start registers both commands).
tests/register.test.ts: the fail-closed test makes $.store.get reject with a stub { deny }. The testing chapter shows { deny } for any mods API call that should fail; that it reaches this hook as a rejection before next is not shown there..catch branch that keeps the result when next.called is true has no test: the kit offers no deterministic way to make this hook throw after next was called (a stub that throws is skipped, and the test then fails instead of reaching the hook). The branch is untested./protect-files off and /protect-files-paths reset are both ways around the guard.Delete this folder, or stop loading it. Its saved keys stay in the plugin store until the store is cleared.
Written for this plugin; MIT, see LICENSE in this folder (a copy of the repository LICENSE). The ../LICENSE one level up covers the BeLazy167 examples, not this one.
hooks/register.ts 71 lines1import type { Register } from 'claude-code'
2
3import { DAMAGED_LIST_TEXT, ENABLED_KEY, PATHS_KEY, checkTarget, parseManage, parseSwitch, readList } from './logic.js'
4
5export const register: Register = (on) => {
6 on('session.start', async ($, e, next) => {
7 // Register commands last: a throw here skips the rest of the hook (mods-doc-mods-api chapter.md:49).
8 // $.command.register and its fields name, description, argumentHint: mods-doc-mods-api chapter.md:34.
9 await $.command.register({ name: 'protect-files', description: 'Turn the protected-files guard on or off', argumentHint: '[on|off]' })
10 await $.command.register({ name: 'protect-files-paths', description: 'Manage the protected paths', argumentHint: 'add <path> | remove <path> | list | reset' })
11 // next(e) passes the event on unchanged (mods-doc-mods-reference chapter.md:69; session.start row at :136).
12 return next(e)
13 })
14
15 // e.args is the text typed after the command name, or an empty string (mods-doc-mods-api chapter.md:40).
16 // $.store.get(key) resolves to the value or undefined and $.store.set(key, value) takes any JSON value
17 // (mods-doc-mods-interfaces chapter.md:744); $.ui.toast shows a notification (mods-doc-mods-api chapter.md:222).
18 on('command.run', { command: 'protect-files' }, async ($, e) => {
19 // A missing key means on: a guard that has never been configured protects.
20 const current = (await $.store.get(ENABLED_KEY)) !== false
21 const result = parseSwitch(e.args, current)
22 if ('usage' in result) return { text: result.usage }
23 await $.store.set(ENABLED_KEY, result.enabled)
24 await $.ui.toast(result.enabled ? 'protect-files on' : 'protect-files off')
25 // {} is an allowed command.run answer (mods-doc-mods-reference chapter.md:113).
26 return {}
27 })
28
29 // e.args, $.store.get and $.store.set as above (mods-doc-mods-api chapter.md:40, mods-doc-mods-interfaces chapter.md:744).
30 on('command.run', { command: 'protect-files-paths' }, async ($, e) => {
31 const stored = await $.store.get(PATHS_KEY)
32 // A damaged value is reported and left alone: replacing it with an empty list would silently
33 // drop every protection the user thought was set.
34 const current = readList(stored)
35 // A damaged list is not an empty one: parse with [] only so that reset is recognised; every other verb on a damaged list is refused below.
36 const result = parseManage(e.args, current ?? [])
37 // reset deletes the key ($.store.delete: mods-doc-mods-interfaces chapter.md:192). The store is this plugin's own, so no
38 // other mod can clear it (mods-doc-mods-api chapter.md:280). It works on a damaged list too: it is the way out of one,
39 // and a delete is explicit, unlike the overwrite that add/remove must never do.
40 if ('reset' in result) {
41 await $.store.delete(PATHS_KEY)
42 return { text: result.text }
43 }
44 if (current === undefined) return { text: DAMAGED_LIST_TEXT }
45 if ('usage' in result) return { text: result.usage }
46 // An answer with no paths (a refused entry) leaves the store untouched.
47 if ('paths' in result) await $.store.set(PATHS_KEY, result.paths)
48 // { text } is an allowed command.run answer (mods-doc-mods-reference chapter.md:113).
49 return { text: result.text }
50 })
51
52 // The matcher { tool: ['Edit', 'Write'] } is shown at mods-doc-mods-and-events chapter.md:129.
53 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
54 // Off is a deliberate policy: every call passes untouched.
55 if ((await $.store.get(ENABLED_KEY)) === false) return next(e)
56 // e.file_path is documented for tool.call (mods-doc-mods-and-events chapter.md:108 and :134).
57 const reason = checkTarget(e.file_path, await $.store.get(PATHS_KEY))
58 // Deny BEFORE next: after next the tool has already run.
59 // Returning { deny } without calling next refuses the call: Claude reads the text as the tool's result
60 // (mods-doc-mods-and-events chapter.md:117 and :124, "Guard or change a tool call"; tool.call row: mods-doc-mods-reference chapter.md:77).
61 if (reason !== undefined) return { deny: reason }
62 // next(e) runs the permission check, then the tool (mods-doc-mods-and-events chapter.md:108).
63 return next(e)
64 })
65 // A hook that throws is skipped and the call would run. Refuse instead, unless
66 // the hook had already called next (then its result stands). next.called and
67 // next.error.kind (throw, timeout or re-entry) are in mods-doc-mods-reference chapter.md:62-63.
68 // The `if (next.called) return next(e)` pattern is shown in mods-doc-mods-and-events chapter.md:331-337.
69 .catch(($, e, next) => (next.called ? next(e) : { deny: 'protect-files: the guard failed, so this call was not allowed: ' + next.error.kind }))
70}
71hooks/logic.ts 151 lines1// Pure decisions for protect-files. No `$` here: register.ts does every API call.
2
3/** Store key: boolean. A missing key means enabled (default ON). */
4export const ENABLED_KEY = 'protect-files.enabled'
5/** Store key: string[] of normalised protected paths. A missing key means an empty list. */
6export const PATHS_KEY = 'protect-files.paths'
7
8export const SWITCH_USAGE = 'usage: /protect-files [on|off]'
9export const PATHS_USAGE = 'usage: /protect-files-paths add <path> | remove <path> | list | reset'
10export const DAMAGED_LIST_TEXT =
11 // Way out: /protect-files-paths reset clears the key with $.store.delete (mods-doc-mods-interfaces chapter.md:192;
12 // the store is this plugin's own, mods-doc-mods-api chapter.md:280), and /protect-files off lets calls through meanwhile.
13 'protect-files: the stored list is damaged, so it was left unchanged. Run /protect-files-paths reset to clear it; that removes every protected path, so add the ones you need again (or run /protect-files off to continue without the guard).'
14
15/**
16 * Normalises a path for comparison. It DOES: turn backslashes into slashes,
17 * collapse repeated slashes, drop `.` segments (so `./a` is `a`), resolve `..`
18 * segments, and drop trailing slashes. It does NOT: follow symlinks, fold case,
19 * expand `~` or globs, handle Windows drive letters, or make a relative path
20 * absolute (this file never sees the working directory).
21 */
22export function normalizePath(raw: string): string {
23 const slashed = raw.replace(/\\/g, '/')
24 const isAbsolute = slashed.startsWith('/')
25 const out: string[] = []
26 for (const part of slashed.split('/')) {
27 if (part === '' || part === '.') continue
28 if (part === '..') {
29 // A `..` above the root stays at the root; above a relative start it is kept.
30 if (out.length > 0 && out[out.length - 1] !== '..') out.pop()
31 else if (!isAbsolute) out.push('..')
32 continue
33 }
34 out.push(part)
35 }
36 return (isAbsolute ? '/' : '') + out.join('/')
37}
38
39function hasPrefixAt(parts: readonly string[], prefix: readonly string[], from: number): boolean {
40 if (from + prefix.length > parts.length) return false
41 return prefix.every((p, k) => parts[from + k] === p)
42}
43
44/**
45 * An absolute entry protects itself and everything below it. A relative entry
46 * protects the same segments wherever they appear in the target path, because
47 * the target is normally absolute and the working directory is unknown here.
48 * Matching is by whole segments: entry `src` does not protect `src2/a.ts`.
49 */
50export function isProtected(target: string, entries: readonly string[]): boolean {
51 const t = normalizePath(target)
52 const targetParts = t.split('/').filter((s) => s !== '')
53 for (const entry of entries) {
54 const n = normalizePath(entry)
55 if (n === '' || n === '/') return true // an empty or root entry protects everything
56 const entryParts = n.split('/').filter((s) => s !== '')
57 if (n.startsWith('/')) {
58 if (t.startsWith('/') && hasPrefixAt(targetParts, entryParts, 0)) return true
59 continue
60 }
61 for (let i = 0; i + entryParts.length <= targetParts.length; i++) {
62 if (hasPrefixAt(targetParts, entryParts, i)) return true
63 }
64 }
65 return false
66}
67
68/**
69 * The stored list as strings: a missing key is an empty list. Anything that is not a string array
70 * is undefined (damaged), and so is a list holding a relative entry whose normalised form starts
71 * with `..`: it can never match (a target path is absolute and normalised), so it would claim a
72 * protection that is not there. `/..` is not such an entry: it normalises to `/`.
73 */
74export function readList(stored: unknown): string[] | undefined {
75 if (stored === undefined) return []
76 if (!Array.isArray(stored) || !stored.every((x) => typeof x === 'string')) return undefined
77 const neverMatches = (x: string): boolean => {
78 const n = normalizePath(x)
79 return !n.startsWith('/') && n.split('/')[0] === '..'
80 }
81 if (stored.some(neverMatches)) return undefined
82 return stored
83}
84
85/**
86 * Returns the refusal text, or undefined when the call may go on. Fails closed:
87 * a missing or non-string target, a stored list that is not a list of strings, or a
88 * relative target while an absolute entry exists (it cannot be compared) is refused
89 * rather than waved through.
90 */
91export function checkTarget(target: unknown, stored: unknown): string | undefined {
92 if (typeof target !== 'string' || target === '') {
93 return 'protect-files: this call has no file path, so it was not allowed'
94 }
95 const entries = readList(stored)
96 if (entries === undefined) {
97 // The same way out as DAMAGED_LIST_TEXT (reset uses $.store.delete: mods-doc-mods-interfaces chapter.md:192; own store: mods-doc-mods-api chapter.md:280).
98 return 'protect-files: the protected list is damaged, so this call was not allowed. Run /protect-files-paths reset to clear it; that removes every protected path, so add the ones you need again (or run /protect-files off to continue without the guard).'
99 }
100 // A relative target cannot be checked against an absolute entry: the working directory is unknown here.
101 if (!normalizePath(target).startsWith('/') && entries.some((x) => normalizePath(x).startsWith('/'))) {
102 return `protect-files: ${target} is a relative path, which cannot be checked against absolute entries, so this call was not allowed`
103 }
104 if (isProtected(target, entries)) {
105 return `protect-files: ${target} is protected. Ask the user to run /protect-files off or /protect-files-paths remove first.`
106 }
107 return undefined
108}
109
110export type SwitchResult = { enabled: boolean } | { usage: string }
111
112/** `/protect-files` toggles, `on` and `off` set, anything else answers usage. */
113export function parseSwitch(args: string, current: boolean): SwitchResult {
114 const word = args.trim().toLowerCase()
115 if (word === '') return { enabled: !current }
116 if (word === 'on') return { enabled: true }
117 if (word === 'off') return { enabled: false }
118 return { usage: SWITCH_USAGE }
119}
120
121/** A result with no `paths` is an answer only: the caller must not touch the store. */
122export type ManageResult = { paths: string[]; text: string } | { reset: true; text: string } | { text: string } | { usage: string }
123
124/** `/protect-files-paths add|remove <path>`, `list` and `reset`. */
125export function parseManage(args: string, current: readonly string[]): ManageResult {
126 const trimmed = args.trim()
127 const space = trimmed.search(/\s/)
128 const verb = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase()
129 const rest = space === -1 ? '' : trimmed.slice(space).trim()
130 // The caller deletes the key: an explicit delete, not an overwrite, so it also works on a damaged list.
131 if (verb === 'reset' && rest === '') return { reset: true, text: 'protect-files: the protected list was cleared' }
132 if (verb === 'list' && rest === '') {
133 // Text only, no paths: the caller must not write the list back on a read-only command.
134 return { text: current.length === 0 ? 'No protected paths' : current.join('\n') }
135 }
136 if ((verb !== 'add' && verb !== 'remove') || rest === '') return { usage: PATHS_USAGE }
137 const entry = normalizePath(rest)
138 if (entry === '') return { usage: PATHS_USAGE }
139 // Refused, not stored: an entry that starts with .. never matches, because a target path is
140 // absolute and normalised, so storing it would tell the user a file is protected when it is not.
141 if (verb === 'add' && entry.split('/')[0] === '..') {
142 return { text: `${entry} cannot be protected: a relative path that starts with .. never matches a file path` }
143 }
144 if (verb === 'add') {
145 if (current.includes(entry)) return { paths: [...current], text: `${entry} is already protected` }
146 return { paths: [...current, entry], text: `Protected ${entry}` }
147 }
148 if (!current.includes(entry)) return { paths: [...current], text: `${entry} was not in the list` }
149 return { paths: current.filter((p) => p !== entry), text: `Unprotected ${entry}` }
150}
151