Turn CLAUDE.md rules into hard blocks: protected paths, no new report files, banned commands, and rules re-pinned every N prompts and after compaction.

A Claude Code mod that turns the rules you keep repeating in CLAUDE.md into blocks Claude cannot ignore: protected paths, no new report files, banned commands, and rules re-pinned into context as the session goes on.
● Bash(echo "hello" >> secret.txt)
⎿ Error: Blocked by house rules: secret.txt is protected (holds a test token). Do not edit,
move or delete it. If the task really needs that, stop and ask the user: they can change
it themselves or edit the rule in .claude/house-rules.json.
● Write(IMPLEMENTATION_SUMMARY.md)
⎿ Error: Blocked by house rules: do not create IMPLEMENTATION_SUMMARY.md (report-style
markdown (SUMMARY, REPORT, NOTES, IMPLEMENTATION, ANALYSIS, CHANGES, PLAN) is not wanted
outside docs/). Put what you meant to write in your reply to the user, ...
● Bash(cd web && npm install react)
⎿ Error: Blocked by house rules: `npm install react` matches /^(npm|yarn) (i|install|add)\b/
(this repo uses pnpm). Run the command this rule asks for instead; ...
CLAUDE.md is context, not enforcement. The model reads it once, weighs it against everything else, and drifts:
Writing those hooks by hand means a shell script per rule, JSON on stdin and exit codes. This mod is the hook, driven by one small JSON file.
| Rule | What is blocked | |||
|---|---|---|---|---|
protect | Edit, MultiEdit, Write and NotebookEdit on matching paths, and Bash that writes, moves or deletes them: redirections (>, >>), tee, rm, mv, cp/ln/install onto them, sed -i, perl -i, truncate, touch, dd of=, git rm/mv/restore/checkout --. For a glob with a slash, deleting or moving a folder that holds a protected path counts too | |||
noNewFiles | Write of a matching file that does not exist yet (editing existing files stays allowed), and Bash that creates one (cat > REPORT.md <<EOF, touch, cp, mv) | |||
commands | A Bash command whose segment matches the regex; each part of a && / `\ | \ | / ; / \ | chain, $(...) and bash -c "..." is tested on its own, with and without sudo/env/VAR=` in front |
pin | The listed rules are added to the context the model reads, on the first prompt, every pinEvery prompts after it and right after each compaction | |||
| default | With no rules file: no new report-style markdown outside docs/ |
The deny reason is the rule's why plus what to do instead, so the model gets a reason it can act on, not a bare refusal.
/house-rules prints the rules in force, the file each came from, how many times each blocked something this session, the pins and any errors in the file. /house-rules init writes a commented starter .claude/house-rules.json if there is none.
.claude/house-rules.json at the project root, merged with ~/.claude/house-rules.json if you have one. Where both name the same glob or regex, the project's entry wins, and so does its pinEvery.
{
"protect": [{ "glob": ".env*", "why": "secrets" }, { "glob": "migrations/applied/**", "why": "applied migrations are immutable" }],
"noNewFiles": [{ "glob": "**/*.md", "except": ["docs/**", "README.md", "CHANGELOG.md"], "why": "no report files" }],
"commands": [{ "match": "^(npm|yarn) (i|install|add)\\b", "why": "this repo uses pnpm" }],
"pin": ["Use uv, not pip.", "Never edit applied migrations."],
"pinEvery": 10
}
protect, noNewFiles and commands ("protect": [".env*"]); the reason is then a generic one..env*, secrets); a glob with a slash is relative to the project root (migrations/applied/**); one starting with / is absolute. A glob that matches a folder covers everything inside it. *, ?, [a-z], [!a-z], ** and {a,b} work.match is a JavaScript regular expression.pinEvery: 0 pins only after compaction.$, like "$comment", is ignored, at the top and inside entries.The file is checked when it loads. A bad regex, a broken glob, an unknown field or a wrong type is shown once as a toast and listed under /house-rules; that entry is skipped and the rest still apply. A file that is not valid JSON at all counts as no file, so the default rule stays on until you fix it.
You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
From the marketplace in this repo:
/plugin marketplace add Jvrd97/claude-house-rules
/plugin install house-rules@house-rules
Or straight from disk, for one session:
git clone https://github.com/Jvrd97/claude-house-rules.git
claude --plugin-dir ./claude-house-rules
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
defaultRules | true | With no rules file, block creating new *SUMMARY*, *REPORT*, *NOTES*, *IMPLEMENTATION*, *ANALYSIS*, *CHANGES* and *PLAN* markdown files outside docs/ and .claude/ (README, CHANGELOG and CONTRIBUTING stay allowed) |
globalFile | true | Also read ~/.claude/house-rules.json |
The default rule matches whole words of the name, case-insensitive: FINAL_SUMMARY.md, implementation-plan.md and TestReport.md are blocked, explanation.md and reporting-api.md are not.
tool.call sees every tool call before it runs, the permission prompt included. For Edit, MultiEdit, Write, NotebookEdit and Bash the mod checks the rules and answers { deny: reason }; the model receives the reason as the tool's error. Every other tool passes without a file system call.session.start and read again on a tool call or a prompt when its mtime changed, so an edit applies on the next call without a restart.$.fs.stat, where it really lands, so a symlink to a protected file is protected too.prompt.submit attaches the pins as context the model reads beside the prompt and the user does not see. session.compact marks that a compaction happened; half a second later the mod appends the pins as a row the model reads ($.session.append). If a prompt comes first, or the append is refused, the pins go with that prompt instead./house-rules init is the only time the mod writes a file: .claude/house-rules.json, and only when it does not exist. Block counts and the prompt counter live in the session's state and are gone when it ends. No network.What it cannot do:
rm "$FILE", python -c "open('x','w')", find . -delete, xargs rm) is not seen. The relative paths in a Bash command are resolved against the session's working directory, plus any cd in the same command; a cd from an earlier Bash call is not tracked..claude/house-rules.json can change them. Keep the file in git and review changes to it like code.Possible false positives: a name glob like secrets covers any folder of that name, and **/*.md with no exceptions blocks every new markdown file, CLAUDE.md included. The starter file lists the usual exceptions.
Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:
tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
CLAUDE.md — это совет, а не запрет: модель читает его и всё равно правит запрещённые файлы, ставит пакеты не тем менеджером и плодит SUMMARY.md. Мод превращает такие правила в блокировки на уровне вызова инструментов.
protect — пути, которые нельзя править, двигать и удалять, в том числе через Bash.noNewFiles — какие новые файлы создавать нельзя; без файла правил по умолчанию запрещены отчёты вида SUMMARY/REPORT/PLAN.md вне docs/.commands — запрещённые команды; проверяется каждая часть цепочки &&, ;, |.pin — правила, которые мод напоминает модели каждые N промптов и сразу после сжатия контекста.Правила лежат в .claude/house-rules.json; /house-rules показывает, что действует и сколько раз сработало, /house-rules init создаёт стартовый файл.
MIT
hooks/register.ts 342 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
3
4import type { LoadedRules, RuleSet } from '../types'
5import {
6 STARTER_FILE,
7 analyzeBash,
8 commandVerdict,
9 defaultRules,
10 emptyRules,
11 filePathOf,
12 globParts,
13 mergeRules,
14 newFileVerdict,
15 parseRules,
16 pinEveryOf,
17 pinText,
18 protectVerdict,
19 report,
20 resolvePath,
21 shellNameMatches,
22 shouldPin,
23} from './logic'
24import type { PathFacts, ShellTarget, Verdict } from './logic'
25
26const COMMAND = 'house-rules'
27const RULES_FILE = '.claude/house-rules.json'
28const GLOBAL_NAME = '~/.claude/house-rules.json'
29const ERROR_TOAST_MS = 10_000
30/** Lets a compaction install its summary before the pins are appended after it. */
31const COMPACT_SETTLE_MS = 500
32/** Paths one Bash call is checked for at most; each costs a stat or two. */
33const MAX_TARGETS = 64
34const GUARDED_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash'])
35
36const loaded = atom({ plugin: 'house-rules', key: 'loaded' } as const, null)
37const blocks = atom({ plugin: 'house-rules', key: 'blocks' } as const, {})
38const prompts = atom({ plugin: 'house-rules', key: 'prompts' } as const, 0)
39const toasted = atom({ plugin: 'house-rules', key: 'toasted' } as const, [])
40const pinPending = atom({ plugin: 'house-rules', key: 'pinPending' } as const, false)
41
42type Settings = { usesDefaults: boolean; readsGlobal: boolean }
43
44/** Where the rules come from and what paths are relative to. */
45type Places = { root: string; roots: string[]; cwd: string; home: string | undefined; project: string; global: string | null }
46
47async function placesOf($: EngineInterface, settings: Settings): Promise<Places> {
48 const root = await $.session.root()
49 const cwd = await $.session.cwd()
50 const home = await $.env.get('HOME')
51 const realRoot = await $.fs
52 .stat(root, { resolve: true })
53 .then(stat => stat.realPath)
54 .catch(() => undefined)
55 const project = `${root}/${RULES_FILE}`
56 const global = settings.readsGlobal && home !== undefined ? `${home}/${RULES_FILE}` : null
57
58 return {
59 root,
60 roots: realRoot === undefined || realRoot === root ? [root] : [root, realRoot],
61 cwd,
62 home,
63 project,
64 global: global === project ? null : global,
65 }
66}
67
68/** A missing file is the normal case, not an error: it reads as null. */
69async function mtimeOf($: EngineInterface, path: string): Promise<number | null> {
70 return $.fs
71 .stat(path)
72 .then(stat => stat.mtimeMs)
73 .catch(() => null)
74}
75
76async function toastNewErrors($: EngineInterface, errors: readonly string[]): Promise<void> {
77 const seen = await read($, toasted)
78 const fresh = errors.filter(error => !seen.includes(error))
79
80 if (fresh.length === 0) {
81 return
82 }
83
84 await update($, toasted, list => [...(list ?? []), ...fresh])
85 const more = fresh.length === 1 ? '' : ` (+${fresh.length - 1} more)`
86 $.ui.toast(`house-rules: ${fresh[0]}${more}. That entry is ignored; /house-rules lists all.`, { timeoutMs: ERROR_TOAST_MS })
87}
88
89/** The rules in force: read again only when a rules file's mtime, the root or the settings changed. */
90async function rulesOf($: EngineInterface, settings: Settings, places: Places): Promise<LoadedRules> {
91 const projectMtime = await mtimeOf($, places.project)
92 const globalMtime = places.global === null ? null : await mtimeOf($, places.global)
93 const key = [places.root, projectMtime, globalMtime, settings.usesDefaults, settings.readsGlobal].join('|')
94 const cached = await read($, loaded)
95
96 if (cached !== null && cached.key === key) {
97 return cached
98 }
99
100 const sources = [
101 { path: places.global, mtime: globalMtime, name: GLOBAL_NAME },
102 { path: places.project, mtime: projectMtime, name: RULES_FILE },
103 ]
104 const sets: RuleSet[] = []
105 const files: string[] = []
106 const errors: string[] = []
107
108 for (const source of sources) {
109 if (source.path === null || source.mtime === null) {
110 continue
111 }
112
113 const text = await $.fs.read(source.path).catch((error: unknown) => {
114 errors.push(`${source.name}: cannot be read (${error instanceof Error ? error.message : String(error)})`)
115
116 return null
117 })
118
119 if (text === null) {
120 continue
121 }
122
123 const parsed = parseRules(text, source.name)
124 errors.push(...parsed.errors)
125
126 if (parsed.isReadable) {
127 sets.push(parsed.rules)
128 files.push(source.name)
129 }
130 }
131
132 const isDefault = files.length === 0 && settings.usesDefaults
133 const next: LoadedRules = {
134 key,
135 rules: isDefault ? defaultRules() : files.length === 0 ? emptyRules() : mergeRules(sets),
136 files,
137 errors,
138 isDefault,
139 }
140
141 await update($, loaded, () => next)
142 await toastNewErrors($, errors)
143
144 return next
145}
146
147async function factsOf($: EngineInterface, path: string, isRemoval: boolean): Promise<PathFacts> {
148 const stat = await $.fs.stat(path, { resolve: true }).catch(() => null)
149 const realPath = stat?.realPath
150
151 return realPath === undefined || realPath === path
152 ? { path, exists: stat !== null, isRemoval }
153 : { path, realPath, exists: true, isRemoval }
154}
155
156/** A target like `/work/*.txt` becomes the files the shell would expand it to, plus itself. */
157async function expandTargets($: EngineInterface, targets: readonly ShellTarget[]): Promise<ShellTarget[]> {
158 const expanded: ShellTarget[] = []
159
160 for (const target of targets) {
161 expanded.push(target)
162 const parts = target.hasGlob && target.intoDir === undefined ? globParts(target.path) : null
163
164 if (parts === null) {
165 continue
166 }
167
168 const entries = await $.fs.list(parts.dir).catch(() => [])
169
170 for (const entry of entries) {
171 if (shellNameMatches(parts.pattern, entry.name)) {
172 expanded.push({ path: `${parts.dir === '/' ? '' : parts.dir}/${entry.name}`, mayCreate: target.mayCreate, hasGlob: false })
173 }
174 }
175 }
176
177 return expanded.slice(0, MAX_TARGETS)
178}
179
180async function judgeBash($: EngineInterface, rules: RuleSet, places: Places, command: string): Promise<Verdict | null> {
181 const analysis = analyzeBash(command, places.cwd, places.home)
182 const byCommand = commandVerdict(rules, analysis.segments)
183
184 if (byCommand !== null || (rules.protect.length === 0 && rules.noNewFiles.length === 0)) {
185 return byCommand
186 }
187
188 for (const target of await expandTargets($, analysis.targets)) {
189 if (target.intoDir !== undefined) {
190 const kind = await $.fs
191 .stat(target.intoDir)
192 .then(stat => stat.kind)
193 .catch(() => null)
194
195 if (kind !== 'dir') {
196 continue
197 }
198 }
199
200 const facts = await factsOf($, target.path, !target.mayCreate)
201 const verdict = protectVerdict(rules, facts, places.roots) ?? (target.mayCreate ? newFileVerdict(rules, facts, places.roots) : null)
202
203 if (verdict !== null) {
204 return verdict
205 }
206 }
207
208 return null
209}
210
211/** The rule a tool call breaks, or null. Tools that write nothing are let through without a file system call. */
212async function judge($: EngineInterface, e: ToolCallInput, settings: Settings): Promise<Verdict | null> {
213 if (!GUARDED_TOOLS.has(e.tool)) {
214 return null
215 }
216
217 const places = await placesOf($, settings)
218 const { rules } = await rulesOf($, settings, places)
219 const input = e as unknown as Readonly<Record<string, unknown>>
220
221 if (e.tool === 'Bash') {
222 return judgeBash($, rules, places, typeof input.command === 'string' ? input.command : '')
223 }
224
225 const written = filePathOf(e.tool, input)
226
227 if (written === null || (rules.protect.length === 0 && rules.noNewFiles.length === 0)) {
228 return null
229 }
230
231 const facts = await factsOf($, resolvePath(places.cwd, written, places.home), false)
232
233 return protectVerdict(rules, facts, places.roots) ?? (e.tool === 'Write' ? newFileVerdict(rules, facts, places.roots) : null)
234}
235
236/** The pinned rules as the model reads them, or null when there are none. */
237async function pinsOf($: EngineInterface, settings: Settings): Promise<{ text: string; every: number } | null> {
238 const current = await rulesOf($, settings, await placesOf($, settings))
239
240 return current.rules.pin.length === 0 ? null : { text: pinText(current.rules.pin, current.files), every: pinEveryOf(current.rules) }
241}
242
243async function appendPinsAfterCompaction($: EngineInterface, settings: Settings): Promise<void> {
244 if (!(await read($, pinPending))) {
245 return
246 }
247
248 const pins = await pinsOf($, settings)
249
250 if (pins === null) {
251 await update($, pinPending, () => false)
252
253 return
254 }
255
256 const stored = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: pins.text }] } }).catch((error: unknown) => {
257 $.ui.log(`house-rules: could not append the pinned rules after compaction (${error instanceof Error ? error.message : String(error)}); they go with the next prompt`)
258
259 return null
260 })
261
262 if (stored !== null && !('deny' in stored && stored.deny !== undefined)) {
263 await update($, pinPending, () => false)
264 }
265}
266
267async function initFile($: EngineInterface, settings: Settings): Promise<string> {
268 const places = await placesOf($, settings)
269
270 if (await $.fs.exists(places.project)) {
271 return `${RULES_FILE} already exists; house-rules leaves it as it is. Run /house-rules to see what it holds.`
272 }
273
274 await $.fs.write(places.project, STARTER_FILE)
275 await update($, loaded, () => null)
276 await rulesOf($, settings, places)
277
278 return `Wrote ${RULES_FILE} with example rules. Edit it: changes apply on the next tool call.\n\n${STARTER_FILE}`
279}
280
281async function showRules($: EngineInterface, settings: Settings): Promise<string> {
282 const current = await rulesOf($, settings, await placesOf($, settings))
283
284 return report(current, await read($, blocks), await read($, prompts))
285}
286
287export const register: Register = (on, options) => {
288 const settings: Settings = { usesDefaults: options.defaultRules !== false, readsGlobal: options.globalFile !== false }
289
290 on('session.start', async ($, e, next) => {
291 await $.command.register({ name: COMMAND, description: 'Show the house rules in force and what they blocked; init writes a starter file', argumentHint: '[init]' })
292 await rulesOf($, settings, await placesOf($, settings))
293
294 return next(e)
295 })
296
297 on('tool.call', async ($, e, next) => {
298 const verdict = await judge($, e, settings)
299
300 if (verdict === null) {
301 return next(e)
302 }
303
304 await update($, blocks, map => ({ ...map, [verdict.ruleId]: (map?.[verdict.ruleId] ?? 0) + 1 }))
305
306 return { deny: verdict.reason }
307 })
308
309 on('prompt.submit', async ($, e, next) => {
310 const count = await update($, prompts, n => (n ?? 0) + 1)
311 const pins = await pinsOf($, settings)
312 const isAfterCompaction = await read($, pinPending)
313
314 if (pins === null || !(isAfterCompaction || shouldPin(count, pins.every))) {
315 return next(e)
316 }
317
318 await update($, pinPending, () => false)
319
320 return next({ ...e, context: [...(e.context ?? []), pins.text] })
321 })
322
323 on('session.compact', async ($, e, next) => {
324 const result = await next(e)
325
326 if (result.skip === undefined && e.trigger !== 'precompute' && e.agentId === undefined) {
327 await update($, pinPending, () => true)
328 $.clock.after(COMPACT_SETTLE_MS, () => {
329 void appendPinsAfterCompaction($, settings)
330 })
331 }
332
333 return result
334 })
335
336 on('command.run', { command: COMMAND }, async ($, e) => {
337 const isInit = e.args.trim().toLowerCase() === 'init'
338
339 return { text: isInit ? await initFile($, settings) : await showRules($, settings) }
340 })
341}
342hooks/logic.ts 1446 lines1import type { CommandRule, LoadedRules, NoNewFilesRule, ProtectRule, RuleSet } from '../types'
2
3export const DEFAULT_PIN_EVERY = 10
4/** A brace glob expands to at most this many plain globs; more is a typo or an attack on the matcher. */
5const MAX_BRACE_EXPANSIONS = 256
6const PROJECT_FILE = '.claude/house-rules.json'
7
8export const GENERIC_WHY = {
9 protect: 'this path is protected by the house rules',
10 noNewFiles: 'new files like this are not wanted in this repo',
11 commands: 'this command is not allowed in this repo',
12} as const
13
14const KNOWN_FIELDS = ['protect', 'noNewFiles', 'commands', 'pin', 'pinEvery'] as const
15
16export const emptyRules = (): RuleSet => ({ protect: [], noNewFiles: [], commands: [], pin: [], pinEvery: null })
17
18// ---------------------------------------------------------------- paths
19
20/** Folds `.`, `..` and repeated slashes; keeps a leading slash. */
21export const normalizePath = (path: string): string => {
22 const isAbsolute = path.startsWith('/')
23 const parts: string[] = []
24
25 for (const part of path.split('/')) {
26 if (part === '' || part === '.') {
27 continue
28 }
29
30 if (part === '..') {
31 if (parts.length > 0 && parts[parts.length - 1] !== '..') {
32 parts.pop()
33 } else if (!isAbsolute) {
34 parts.push('..')
35 }
36
37 continue
38 }
39
40 parts.push(part)
41 }
42
43 const joined = parts.join('/')
44
45 return isAbsolute ? `/${joined}` : joined === '' ? '.' : joined
46}
47
48/** An absolute path for `path` as a shell in `base` would read it; `~/` needs `home`. */
49export const resolvePath = (base: string, path: string, home?: string): string => {
50 if (path.startsWith('/')) {
51 return normalizePath(path)
52 }
53
54 if ((path === '~' || path.startsWith('~/')) && home !== undefined) {
55 return normalizePath(`${home}/${path.slice(1)}`)
56 }
57
58 return normalizePath(`${base}/${path}`)
59}
60
61/** The path relative to the first root it lies under, '' for a root itself, null when outside all. */
62export const relativeTo = (roots: readonly string[], absolute: string): string | null => {
63 const path = normalizePath(absolute)
64
65 for (const root of roots) {
66 const clean = normalizePath(root)
67
68 if (path === clean) {
69 return ''
70 }
71
72 const prefix = clean === '/' ? '/' : `${clean}/`
73
74 if (path.startsWith(prefix)) {
75 return path.slice(prefix.length)
76 }
77 }
78
79 return null
80}
81
82export const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
83
84const dirName = (path: string): string => {
85 const cut = path.lastIndexOf('/')
86
87 return cut <= 0 ? (cut === 0 ? '/' : '.') : path.slice(0, cut)
88}
89
90/** 'a/b/c' → ['a', 'a/b', 'a/b/c']; '/x/y' → ['/x', '/x/y']: the path and every folder above it. */
91const prefixesOf = (path: string): string[] => {
92 const isAbsolute = path.startsWith('/')
93 const parts = path.split('/').filter(part => part !== '')
94
95 return parts.map((_, index) => `${isAbsolute ? '/' : ''}${parts.slice(0, index + 1).join('/')}`)
96}
97
98// ---------------------------------------------------------------- globs
99
100const splitTopLevel = (body: string): string[] => {
101 const parts: string[] = []
102 let depth = 0
103 let start = 0
104
105 for (let index = 0; index < body.length; index++) {
106 const char = body[index]
107
108 if (char === '\\') {
109 index++
110 } else if (char === '{') {
111 depth++
112 } else if (char === '}') {
113 depth--
114 } else if (char === ',' && depth === 0) {
115 parts.push(body.slice(start, index))
116 start = index + 1
117 }
118 }
119
120 parts.push(body.slice(start))
121
122 return parts
123}
124
125/** `{a,b}c` → ['ac', 'bc'], nested groups too; a group with no comma stays literal. */
126export const expandBraces = (glob: string): string[] => {
127 let depth = 0
128 let start = -1
129
130 for (let index = 0; index < glob.length; index++) {
131 const char = glob[index]
132
133 if (char === '\\') {
134 index++
135 } else if (char === '{') {
136 if (depth === 0) {
137 start = index
138 }
139
140 depth++
141 } else if (char === '}' && depth > 0) {
142 depth--
143
144 if (depth === 0) {
145 const parts = splitTopLevel(glob.slice(start + 1, index))
146
147 if (parts.length > 1) {
148 const head = glob.slice(0, start)
149 const tail = glob.slice(index + 1)
150
151 return parts.flatMap(part => expandBraces(`${head}${part}${tail}`)).slice(0, MAX_BRACE_EXPANSIONS + 1)
152 }
153 }
154 }
155 }
156
157 return [glob]
158}
159
160/** Why a glob cannot be used, or null when it can. */
161export const globProblem = (glob: string): string | null => {
162 if (glob.trim() === '') {
163 return 'is empty'
164 }
165
166 let braces = 0
167 let isInClass = false
168
169 for (let index = 0; index < glob.length; index++) {
170 const char = glob[index]
171
172 if (char === '\\') {
173 index++
174 } else if (isInClass) {
175 isInClass = char !== ']'
176 } else if (char === '[') {
177 isInClass = true
178 // A ']' right after '[' (or '[!') is a member, not the end.
179 if (glob[index + 1] === '!' || glob[index + 1] === '^') {
180 index++
181 }
182
183 if (glob[index + 1] === ']') {
184 index++
185 }
186 } else if (char === '{') {
187 braces++
188 } else if (char === '}') {
189 braces--
190
191 if (braces < 0) {
192 return 'has a "}" with no "{" before it'
193 }
194 }
195 }
196
197 if (isInClass) {
198 return 'has a "[" that is never closed'
199 }
200
201 if (braces > 0) {
202 return 'has a "{" that is never closed'
203 }
204
205 const expanded = expandBraces(glob)
206
207 if (expanded.length > MAX_BRACE_EXPANSIONS) {
208 return `expands to more than ${MAX_BRACE_EXPANSIONS} patterns`
209 }
210
211 for (const one of expanded) {
212 if (one.split('/').some(segment => segment.includes('**') && segment !== '**')) {
213 return 'uses "**" inside a name; write "**/" for any folders or "*" for part of a name'
214 }
215 }
216
217 return null
218}
219
220const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
221
222/** One path segment of a glob as a regular expression source. */
223const segmentSource = (segment: string): string => {
224 let out = ''
225
226 for (let index = 0; index < segment.length; index++) {
227 const char = segment[index] ?? ''
228
229 if (char === '\\') {
230 out += escapeRegExp(segment[index + 1] ?? '')
231 index++
232 } else if (char === '*') {
233 out += '[^/]*'
234 } else if (char === '?') {
235 out += '[^/]'
236 } else if (char === '[') {
237 const close = segment.indexOf(']', index + 2)
238
239 if (close === -1) {
240 out += '\\['
241 continue
242 }
243
244 let body = segment.slice(index + 1, close)
245 const isNegated = body.startsWith('!') || body.startsWith('^')
246
247 if (isNegated) {
248 body = body.slice(1)
249 }
250
251 out += `[${isNegated ? '^' : ''}${body.replace(/\\/g, '\\\\').replace(/]/g, '\\]')}]`
252 index = close
253 } else {
254 out += escapeRegExp(char)
255 }
256 }
257
258 return out
259}
260
261const plainGlobSource = (glob: string): string => {
262 const segments = glob.split('/')
263
264 return segments
265 .map((segment, index) => {
266 const isLast = index === segments.length - 1
267
268 if (segment === '**') {
269 return isLast ? '.*' : '(?:[^/]+/)*'
270 }
271
272 return segmentSource(segment) + (isLast ? '' : '/')
273 })
274 .join('')
275}
276
277const cleanGlob = (glob: string): string => glob.trim().replace(/^(\.\/)+/, '').replace(/(.)\/+$/, '$1')
278
279const compiled = new Map<string, RegExp>()
280
281/** The whole glob, braces expanded, as one anchored regular expression. */
282export const globRegExp = (glob: string, ignoreCase = false): RegExp => {
283 const key = `${ignoreCase ? 'i' : ''}:${glob}`
284 const known = compiled.get(key)
285
286 if (known !== undefined) {
287 return known
288 }
289
290 const sources = expandBraces(cleanGlob(glob)).slice(0, MAX_BRACE_EXPANSIONS).map(plainGlobSource)
291 const made = new RegExp(`^(?:${sources.join('|')})$`, ignoreCase ? 'i' : '')
292 compiled.set(key, made)
293
294 return made
295}
296
297/**
298 * Whether a glob covers an absolute path, gitignore style: a glob with no
299 * slash matches a file or folder name at any depth, one with a slash is
300 * relative to the project root, one starting with '/' is an absolute path,
301 * and a glob that matches a folder covers everything inside it.
302 */
303export const globCovers = (glob: string, absolute: string, roots: readonly string[], ignoreCase = false): boolean => {
304 const clean = cleanGlob(glob)
305 const pattern = globRegExp(clean, ignoreCase)
306 const path = normalizePath(absolute)
307
308 if (clean.startsWith('/')) {
309 return prefixesOf(path).some(prefix => pattern.test(prefix))
310 }
311
312 const relative = relativeTo(roots, path)
313
314 if (clean.includes('/')) {
315 return relative !== null && relative !== '' && prefixesOf(relative).some(prefix => pattern.test(prefix))
316 }
317
318 if (relative === null) {
319 return pattern.test(baseName(path))
320 }
321
322 return relative.split('/').some(segment => segment !== '' && pattern.test(segment))
323}
324
325/** A shell glob in one file name (`*.txt`), as a shell expands it: `*` skips names starting with a dot. */
326export const shellNameMatches = (pattern: string, name: string): boolean => {
327 if (name.startsWith('.') && !pattern.startsWith('.')) {
328 return false
329 }
330
331 return new RegExp(`^${segmentSource(pattern)}$`).test(name)
332}
333
334// ---------------------------------------------------------------- rules file
335
336const REPORT_WORDS = new Set(['SUMMARY', 'SUMMARIES', 'REPORT', 'REPORTS', 'NOTES', 'IMPLEMENTATION', 'ANALYSIS', 'ANALYSES', 'CHANGES', 'PLAN', 'PLANS'])
337
338/**
339 * Whether a file name reads as a report Claude writes for itself:
340 * FINAL_SUMMARY.md, implementation-plan.md, TestReport.md. Whole words only,
341 * so explanation.md and reporting-api.md stay allowed.
342 */
343export const isReportName = (name: string): boolean => {
344 if (!/\.md$/i.test(name)) {
345 return false
346 }
347
348 const words = name
349 .slice(0, -'.md'.length)
350 .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
351 .split(/[^A-Za-z0-9]+/)
352
353 return words.some(word => REPORT_WORDS.has(word.toUpperCase()))
354}
355
356export const DEFAULT_SOURCE = 'built-in default'
357export const DEFAULT_RULE_ID = 'noNewFiles:report-files'
358
359/** The one rule that holds with no rules file: no new report-style markdown outside docs/. */
360export const defaultRules = (): RuleSet => ({
361 ...emptyRules(),
362 noNewFiles: [
363 {
364 kind: 'noNewFiles',
365 id: DEFAULT_RULE_ID,
366 match: { type: 'report-words' },
367 except: ['docs/**', '.claude/**', 'README.md', 'CHANGELOG.md', 'CONTRIBUTING.md'],
368 why: 'report-style markdown (SUMMARY, REPORT, NOTES, IMPLEMENTATION, ANALYSIS, CHANGES, PLAN) is not wanted outside docs/',
369 source: DEFAULT_SOURCE,
370 },
371 ],
372})
373
374const isRecord = (value: unknown): value is Record<string, unknown> =>
375 typeof value === 'object' && value !== null && !Array.isArray(value)
376
377const describe = (value: unknown): string => (Array.isArray(value) ? 'a list' : value === null ? 'null' : typeof value)
378
379type Entry = { pattern: string; why: string; except: string[] }
380
381/** One `protect` / `noNewFiles` / `commands` entry: a bare string, or an object with its key and an optional why. */
382const entryOf = (
383 value: unknown,
384 where: string,
385 key: 'glob' | 'match',
386 allowsExcept: boolean,
387 fallbackWhy: string,
388 errors: string[],
389): Entry | null => {
390 if (typeof value === 'string') {
391 return { pattern: value, why: fallbackWhy, except: [] }
392 }
393
394 if (!isRecord(value)) {
395 errors.push(`${where} must be a string or an object with "${key}", got ${describe(value)}`)
396
397 return null
398 }
399
400 const allowed = new Set([key, 'why', ...(allowsExcept ? ['except'] : [])])
401
402 for (const field of Object.keys(value)) {
403 if (!field.startsWith('$') && !allowed.has(field)) {
404 errors.push(`${where} has an unknown field "${field}" (allowed: ${[...allowed].join(', ')})`)
405 }
406 }
407
408 const pattern = value[key]
409
410 if (typeof pattern !== 'string') {
411 errors.push(`${where}.${key} must be a string`)
412
413 return null
414 }
415
416 const why = value.why
417
418 if (why !== undefined && (typeof why !== 'string' || why.trim() === '')) {
419 errors.push(`${where}.why must be a non-empty string`)
420 }
421
422 const except: string[] = []
423 const rawExcept = value.except
424
425 if (rawExcept !== undefined) {
426 if (!Array.isArray(rawExcept)) {
427 errors.push(`${where}.except must be a list of globs`)
428 } else {
429 rawExcept.forEach((item: unknown, index) => {
430 const problem = typeof item === 'string' ? globProblem(item) : 'is not a string'
431
432 if (problem === null && typeof item === 'string') {
433 except.push(item)
434 } else {
435 errors.push(`${where}.except[${index}] ${problem ?? ''}`.trim())
436 }
437 })
438 }
439 }
440
441 return { pattern, why: typeof why === 'string' && why.trim() !== '' ? why.trim() : fallbackWhy, except }
442}
443
444const listOf = (data: Record<string, unknown>, field: string, source: string, errors: string[]): unknown[] => {
445 const value = data[field]
446
447 if (value === undefined) {
448 return []
449 }
450
451 if (!Array.isArray(value)) {
452 errors.push(`${source}: "${field}" must be a list, got ${describe(value)}`)
453
454 return []
455 }
456
457 return value
458}
459
460/** `isReadable` is false when the file is not a JSON object at all: then it counts as no file. */
461export type Parsed = { rules: RuleSet; errors: string[]; isReadable: boolean }
462
463/** Reads one rules file. Bad entries are reported and skipped; the good ones still apply. */
464export const parseRules = (text: string, source: string): Parsed => {
465 const rules = emptyRules()
466 const errors: string[] = []
467 let data: unknown
468
469 try {
470 data = JSON.parse(text)
471 } catch (error) {
472 const message = error instanceof Error ? error.message : String(error)
473
474 return { rules, errors: [`${source}: not valid JSON (${message})`], isReadable: false }
475 }
476
477 if (!isRecord(data)) {
478 return { rules, errors: [`${source}: must be a JSON object, got ${describe(data)}`], isReadable: false }
479 }
480
481 for (const field of Object.keys(data)) {
482 if (!field.startsWith('$') && !(KNOWN_FIELDS as readonly string[]).includes(field)) {
483 errors.push(`${source}: unknown field "${field}" (known: ${KNOWN_FIELDS.join(', ')})`)
484 }
485 }
486
487 listOf(data, 'protect', source, errors).forEach((value, index) => {
488 const where = `${source}: protect[${index}]`
489 const entry = entryOf(value, where, 'glob', false, GENERIC_WHY.protect, errors)
490 const problem = entry === null ? null : globProblem(entry.pattern)
491
492 if (entry !== null && problem !== null) {
493 errors.push(`${where} "${entry.pattern}" ${problem}`)
494 } else if (entry !== null) {
495 rules.protect.push({ kind: 'protect', id: `protect:${entry.pattern}`, glob: entry.pattern, why: entry.why, source })
496 }
497 })
498
499 listOf(data, 'noNewFiles', source, errors).forEach((value, index) => {
500 const where = `${source}: noNewFiles[${index}]`
501 const entry = entryOf(value, where, 'glob', true, GENERIC_WHY.noNewFiles, errors)
502 const problem = entry === null ? null : globProblem(entry.pattern)
503
504 if (entry !== null && problem !== null) {
505 errors.push(`${where} "${entry.pattern}" ${problem}`)
506 } else if (entry !== null) {
507 rules.noNewFiles.push({
508 kind: 'noNewFiles',
509 id: `noNewFiles:${entry.pattern}`,
510 match: { type: 'glob', glob: entry.pattern },
511 except: entry.except,
512 why: entry.why,
513 source,
514 })
515 }
516 })
517
518 listOf(data, 'commands', source, errors).forEach((value, index) => {
519 const where = `${source}: commands[${index}]`
520 const entry = entryOf(value, where, 'match', false, GENERIC_WHY.commands, errors)
521
522 if (entry === null) {
523 return
524 }
525
526 try {
527 new RegExp(entry.pattern)
528 rules.commands.push({ kind: 'commands', id: `commands:${entry.pattern}`, match: entry.pattern, why: entry.why, source })
529 } catch (error) {
530 const message = error instanceof Error ? error.message : String(error)
531 errors.push(`${where}.match is not a valid regular expression (${message})`)
532 }
533 })
534
535 listOf(data, 'pin', source, errors).forEach((value, index) => {
536 if (typeof value === 'string' && value.trim() !== '') {
537 rules.pin.push(value.trim())
538 } else {
539 errors.push(`${source}: pin[${index}] must be a non-empty string`)
540 }
541 })
542
543 const every = data.pinEvery
544
545 if (every !== undefined) {
546 if (typeof every === 'number' && Number.isInteger(every) && every >= 0) {
547 rules.pinEvery = every
548 } else {
549 errors.push(`${source}: pinEvery must be a whole number of prompts (0 pins only after compaction)`)
550 }
551 }
552
553 return { rules, errors, isReadable: true }
554}
555
556const byId = <T extends { id: string }>(lists: readonly T[][]): T[] => {
557 const merged = new Map<string, T>()
558
559 for (const list of lists) {
560 for (const rule of list) {
561 merged.set(rule.id, rule)
562 }
563 }
564
565 return [...merged.values()]
566}
567
568/** Merges rule sets in order; a later set (the project's) wins a rule with the same pattern, and pinEvery. */
569export const mergeRules = (sets: readonly RuleSet[]): RuleSet => ({
570 protect: byId(sets.map(set => set.protect)),
571 noNewFiles: byId(sets.map(set => set.noNewFiles)),
572 commands: byId(sets.map(set => set.commands)),
573 pin: [...new Set(sets.flatMap(set => set.pin))],
574 pinEvery: sets.reduce<number | null>((value, set) => set.pinEvery ?? value, null),
575})
576
577export const pinEveryOf = (rules: RuleSet): number => rules.pinEvery ?? DEFAULT_PIN_EVERY
578
579// ---------------------------------------------------------------- shell
580
581/** Index of the ')' closing the '(' just before `from`, quotes respected; the end when none. */
582const closingParen = (text: string, from: number): number => {
583 let depth = 1
584 let quote: string | null = null
585
586 for (let index = from; index < text.length; index++) {
587 const char = text[index]
588
589 if (quote !== null) {
590 if (char === '\\' && quote === '"') {
591 index++
592 } else if (char === quote) {
593 quote = null
594 }
595 } else if (char === '\\') {
596 index++
597 } else if (char === '"' || char === "'") {
598 quote = char
599 } else if (char === '(') {
600 depth++
601 } else if (char === ')') {
602 depth--
603
604 if (depth === 0) {
605 return index
606 }
607 }
608 }
609
610 return text.length
611}
612
613const HEREDOC = /^<<(-?)\s*(['"]?)([A-Za-z_][\w-]*)\2/
614
615/**
616 * Splits a shell command into the simple commands it runs: at `&&`, `||`,
617 * `;`, `|`, `&` and newlines outside quotes. Heredoc bodies are skipped, and
618 * the insides of `$(...)` and backticks are added as commands of their own.
619 */
620export const splitSegments = (command: string): string[] => {
621 const segments: string[] = []
622 const nested: string[] = []
623 const heredocs: { delimiter: string; isIndented: boolean }[] = []
624 let current = ''
625 let quote: '"' | "'" | null = null
626 let index = 0
627
628 const flush = () => {
629 if (current.trim() !== '') {
630 segments.push(current.trim())
631 }
632
633 current = ''
634 }
635
636 while (index < command.length) {
637 const char = command[index] ?? ''
638 const pair = command.slice(index, index + 2)
639
640 if (quote === "'") {
641 current += char
642 quote = char === "'" ? null : quote
643 index++
644 continue
645 }
646
647 if (char === '\\') {
648 current += pair
649 index += 2
650 continue
651 }
652
653 if (pair === '$(' && command[index + 2] !== '(') {
654 const end = closingParen(command, index + 2)
655 nested.push(...splitSegments(command.slice(index + 2, end)))
656 current += command.slice(index, end + 1)
657 index = end + 1
658 continue
659 }
660
661 if (char === '`') {
662 const found = command.indexOf('`', index + 1)
663 const end = found === -1 ? command.length : found
664 nested.push(...splitSegments(command.slice(index + 1, end)))
665 current += command.slice(index, end + 1)
666 index = end + 1
667 continue
668 }
669
670 if (quote === '"') {
671 current += char
672 quote = char === '"' ? null : quote
673 index++
674 continue
675 }
676
677 if (char === '"' || char === "'") {
678 quote = char
679 current += char
680 index++
681 continue
682 }
683
684 const heredoc = HEREDOC.exec(command.slice(index))
685
686 if (heredoc !== null && command[index + 2] !== '<') {
687 heredocs.push({ delimiter: heredoc[3] ?? '', isIndented: heredoc[1] === '-' })
688 current += heredoc[0]
689 index += heredoc[0].length
690 continue
691 }
692
693 if (char === '\n') {
694 flush()
695 index++
696
697 for (const { delimiter, isIndented } of heredocs.splice(0)) {
698 while (index < command.length) {
699 const lineEnd = command.indexOf('\n', index)
700 const end = lineEnd === -1 ? command.length : lineEnd
701 const line = command.slice(index, end)
702 index = end + 1
703
704 if ((isIndented ? line.replace(/^\t+/, '') : line) === delimiter) {
705 break
706 }
707 }
708 }
709
710 continue
711 }
712
713 if (pair === '&&' || pair === '||') {
714 flush()
715 index += 2
716 continue
717 }
718
719 const isRedirectAmp = char === '&' && (command[index - 1] === '>' || command[index + 1] === '>')
720
721 if (char === ';' || char === '|' || (char === '&' && !isRedirectAmp)) {
722 flush()
723 index++
724 continue
725 }
726
727 current += char
728 index++
729 }
730
731 flush()
732
733 return [...segments, ...nested]
734}
735
736/** A shell word with its quotes removed; `hasGlob` / `hasVariable` say whether the shell would rewrite it. */
737export type Word = { text: string; isOperator: boolean; isQuoted: boolean; hasGlob: boolean; hasVariable: boolean }
738
739const word = (text: string, flags: Partial<Omit<Word, 'text'>> = {}): Word => ({
740 text,
741 isOperator: false,
742 isQuoted: false,
743 hasGlob: false,
744 hasVariable: false,
745 ...flags,
746})
747
748/** The words of one simple command; redirection operators (`>`, `2>>`, `&>`, `>&2`) are words of their own. */
749export const wordsOf = (segment: string): Word[] => {
750 const words: Word[] = []
751 let text = ''
752 let isStarted = false
753 let isQuoted = false
754 let hasGlob = false
755 let hasVariable = false
756 let index = 0
757
758 const push = () => {
759 if (isStarted) {
760 words.push(word(text, { isQuoted, hasGlob, hasVariable }))
761 }
762
763 text = ''
764 isStarted = false
765 isQuoted = false
766 hasGlob = false
767 hasVariable = false
768 }
769
770 while (index < segment.length) {
771 const char = segment[index] ?? ''
772
773 if (/\s/.test(char) || char === '(' || char === ')') {
774 push()
775 index++
776 } else if (char === "'") {
777 const found = segment.indexOf("'", index + 1)
778 const end = found === -1 ? segment.length : found
779 text += segment.slice(index + 1, end)
780 isStarted = true
781 isQuoted = true
782 index = end + 1
783 } else if (char === '"') {
784 let cursor = index + 1
785
786 while (cursor < segment.length && segment[cursor] !== '"') {
787 if (segment[cursor] === '\\' && cursor + 1 < segment.length) {
788 cursor++
789 } else if (segment[cursor] === '$' || segment[cursor] === '`') {
790 hasVariable = true
791 }
792
793 text += segment[cursor] ?? ''
794 cursor++
795 }
796
797 isStarted = true
798 isQuoted = true
799 index = cursor + 1
800 } else if (char === '\\') {
801 text += segment[index + 1] ?? ''
802 isStarted = true
803 index += 2
804 } else if (char === '$' && segment[index + 1] === '(') {
805 const end = closingParen(segment, index + 2)
806 text += segment.slice(index, end + 1)
807 isStarted = true
808 hasVariable = true
809 index = end + 1
810 } else if (char === '`') {
811 const found = segment.indexOf('`', index + 1)
812 const end = found === -1 ? segment.length : found
813 text += segment.slice(index, end + 1)
814 isStarted = true
815 hasVariable = true
816 index = end + 1
817 } else if (char === '>' || char === '<') {
818 let operator = ''
819
820 if (isStarted && !isQuoted && /^(\d+|&)$/.test(text)) {
821 operator = text
822 text = ''
823 isStarted = false
824 } else {
825 push()
826 }
827
828 let cursor = index
829
830 while (cursor < segment.length && (segment[cursor] === '>' || segment[cursor] === '<')) {
831 cursor++
832 }
833
834 operator += segment.slice(index, cursor)
835
836 if (segment[cursor] === '|' && operator.endsWith('>')) {
837 operator += '|'
838 cursor++
839 }
840
841 if (segment[cursor] === '&') {
842 const duplicate = /^&(\d+|-)/.exec(segment.slice(cursor))
843 const taken = duplicate === null ? '&' : duplicate[0]
844 operator += taken
845 cursor += taken.length
846 }
847
848 words.push(word(operator, { isOperator: true }))
849 index = cursor
850 } else {
851 if (char === '*' || char === '?' || char === '[') {
852 hasGlob = true
853 }
854
855 if (char === '$') {
856 hasVariable = true
857 }
858
859 text += char
860 isStarted = true
861 index++
862 }
863 }
864
865 push()
866
867 return words.filter(one => one.isOperator || one.isQuoted || (one.text !== '{' && one.text !== '}'))
868}
869
870const SPECIAL_FILES = /^\/dev\/(null|stdout|stderr|tty|fd\/\d+)$/
871
872type Redirect = { operator: string; target: Word }
873
874/** The command's own words, and what its redirections point at. */
875const splitRedirects = (words: readonly Word[]): { argv: Word[]; redirects: Redirect[] } => {
876 const argv: Word[] = []
877 const redirects: Redirect[] = []
878
879 for (let index = 0; index < words.length; index++) {
880 const current = words[index]
881
882 if (current === undefined) {
883 continue
884 }
885
886 if (!current.isOperator) {
887 argv.push(current)
888 continue
889 }
890
891 const isDuplicate = /&(\d+|-)$/.test(current.text)
892 const target = words[index + 1]
893
894 if (!isDuplicate && target !== undefined && !target.isOperator) {
895 redirects.push({ operator: current.text, target })
896 index++
897 }
898 }
899
900 return { argv, redirects }
901}
902
903const WRAPPER_VALUE_OPTIONS: Readonly<Record<string, readonly string[]>> = {
904 sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-U'],
905 doas: ['-u', '-C'],
906 env: ['-u', '-C', '-S'],
907 nice: ['-n'],
908 ionice: ['-c', '-n', '-p'],
909 timeout: ['-s', '-k'],
910 stdbuf: ['-i', '-o', '-e'],
911 command: [],
912 builtin: [],
913 nohup: [],
914 time: [],
915 exec: ['-a'],
916}
917
918const isAssignment = (one: Word): boolean => !one.isQuoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(one.text)
919
920/** Drops leading `VAR=value` words and wrappers like `sudo`, `env`, `nice -n 5`, `timeout 10`. */
921export const stripWrappers = (argv: readonly Word[]): Word[] => {
922 let rest = [...argv]
923
924 for (;;) {
925 const head = rest[0]
926
927 if (head === undefined) {
928 return rest
929 }
930
931 if (isAssignment(head)) {
932 rest = rest.slice(1)
933 continue
934 }
935
936 const name = baseName(head.text)
937 const valueOptions = WRAPPER_VALUE_OPTIONS[name]
938
939 if (valueOptions === undefined) {
940 return rest
941 }
942
943 let index = 1
944
945 while (index < rest.length) {
946 const current = rest[index]
947
948 if (current === undefined || !(current.text.startsWith('-') || (name === 'env' && isAssignment(current)))) {
949 break
950 }
951
952 index += valueOptions.includes(current.text) ? 2 : 1
953
954 if (current.text === '--') {
955 break
956 }
957 }
958
959 if (name === 'timeout') {
960 index++
961 }
962
963 rest = rest.slice(index)
964 }
965}
966
967/** The operands of a command: words that are not options, nor the values of `valueOptions`; all after `--`. */
968const operandsOf = (args: readonly Word[], valueOptions: readonly string[] = []): Word[] => {
969 const operands: Word[] = []
970 let isPastOptions = false
971
972 for (let index = 0; index < args.length; index++) {
973 const current = args[index]
974
975 if (current === undefined) {
976 continue
977 }
978
979 if (isPastOptions || !current.text.startsWith('-') || current.text === '-') {
980 operands.push(current)
981 } else if (current.text === '--') {
982 isPastOptions = true
983 } else if (valueOptions.includes(current.text)) {
984 index++
985 }
986 }
987
988 return operands
989}
990
991const optionValue = (args: readonly Word[], names: readonly string[]): Word | undefined => {
992 const index = args.findIndex(current => names.includes(current.text))
993
994 return index === -1 ? undefined : args[index + 1]
995}
996
997/** A path the command writes, deletes or moves; `intoDir` means "only if that path is a folder". */
998export type RawTarget = { word: Word; mayCreate: boolean; intoDir?: Word }
999
1000const copyLike = (args: readonly Word[], valueOptions: readonly string[], movesSources: boolean): RawTarget[] => {
1001 const operands = operandsOf(args, valueOptions)
1002 const named = optionValue(args, ['-t', '--target-directory'])
1003 const sources = named === undefined ? operands.slice(0, -1) : operands
1004 const destination = named ?? operands[operands.length - 1]
1005
1006 if (destination === undefined || sources.length === 0) {
1007 return []
1008 }
1009
1010 const moved = movesSources ? sources.map(source => ({ word: source, mayCreate: false })) : []
1011 const intoDir = sources.map(source => ({
1012 word: word(baseName(source.text.replace(/\/+$/, '')), { hasGlob: source.hasGlob, hasVariable: source.hasVariable }),
1013 mayCreate: true,
1014 intoDir: destination,
1015 }))
1016 const asFile = named === undefined && sources.length === 1 ? [{ word: destination, mayCreate: true }] : []
1017
1018 return [...moved, ...asFile, ...intoDir]
1019}
1020
1021/** `-i`, `-i.bak`, `-Ei`, `--in-place`: the bundled flags before the `i` are the ones each tool takes without a value. */
1022const isInPlace = (args: readonly Word[], bundled: RegExp): boolean =>
1023 args.some(current => current.text === '--in-place' || current.text.startsWith('--in-place=') || bundled.test(current.text))
1024
1025const SED_IN_PLACE = /^-[nErsuz]*i/
1026const PERL_IN_PLACE = /^-[pnlaws0-9]*i/
1027/** A perl option that ends in `e` (`-e`, `-pe`) takes the script as the next word; `-ie` is `-i` with suffix `e`. */
1028const PERL_SCRIPT_OPTION = /^-[A-Za-hj-z0-9]*[eE]$/
1029
1030const sedTargets = (args: readonly Word[]): RawTarget[] => {
1031 if (!isInPlace(args, SED_IN_PLACE)) {
1032 return []
1033 }
1034
1035 // BSD sed spells "no backup" as `-i ''`: that empty word is the suffix, not the script.
1036 const cleaned = args.filter((current, index) => !(current.isQuoted && current.text === '' && args[index - 1]?.text === '-i'))
1037 const operands = operandsOf(cleaned, ['-e', '--expression', '-f', '--file', '-l'])
1038 const hasScriptOption = cleaned.some(current => ['-e', '--expression', '-f', '--file'].includes(current.text))
1039
1040 return (hasScriptOption ? operands : operands.slice(1)).map(current => ({ word: current, mayCreate: false }))
1041}
1042
1043const perlTargets = (args: readonly Word[]): RawTarget[] => {
1044 if (!isInPlace(args, PERL_IN_PLACE)) {
1045 return []
1046 }
1047
1048 const operands: Word[] = []
1049 let hasScript = false
1050
1051 for (let index = 0; index < args.length; index++) {
1052 const current = args[index]
1053
1054 if (current === undefined) {
1055 continue
1056 }
1057
1058 if (PERL_SCRIPT_OPTION.test(current.text)) {
1059 hasScript = true
1060 index++
1061 } else if (!current.text.startsWith('-')) {
1062 operands.push(current)
1063 }
1064 }
1065
1066 return (hasScript ? operands : operands.slice(1)).map(current => ({ word: current, mayCreate: false }))
1067}
1068
1069const gitTargets = (args: readonly Word[]): RawTarget[] => {
1070 let index = 0
1071
1072 while (index < args.length && (args[index]?.text ?? '').startsWith('-')) {
1073 index += ['-C', '-c', '--git-dir', '--work-tree'].includes(args[index]?.text ?? '') ? 2 : 1
1074 }
1075
1076 const sub = args[index]?.text
1077 const rest = args.slice(index + 1)
1078
1079 switch (sub) {
1080 case 'rm':
1081 return operandsOf(rest).map(current => ({ word: current, mayCreate: false }))
1082 case 'mv':
1083 return copyLike(rest, [], true)
1084 case 'restore':
1085 return operandsOf(rest, ['-s', '--source']).map(current => ({ word: current, mayCreate: false }))
1086 case 'checkout': {
1087 const dashes = rest.findIndex(current => current.text === '--')
1088
1089 return dashes === -1 ? [] : rest.slice(dashes + 1).map(current => ({ word: current, mayCreate: false }))
1090 }
1091 default:
1092 return []
1093 }
1094}
1095
1096/** What one command (wrappers already stripped) writes, deletes or moves, by its name. */
1097const commandTargets = (argv: readonly Word[]): RawTarget[] => {
1098 const name = baseName(argv[0]?.text ?? '')
1099 const args = argv.slice(1)
1100 const each = (list: Word[], mayCreate: boolean): RawTarget[] => list.map(current => ({ word: current, mayCreate }))
1101
1102 switch (name) {
1103 case 'rm':
1104 case 'unlink':
1105 case 'rmdir':
1106 return each(operandsOf(args), false)
1107 case 'shred':
1108 return each(operandsOf(args, ['-n', '-s', '--iterations', '--size']), false)
1109 case 'touch':
1110 return each(operandsOf(args, ['-r', '-t', '-d', '--reference', '--date']), true)
1111 case 'truncate':
1112 return each(operandsOf(args, ['-s', '-r', '--size', '--reference']), true)
1113 case 'tee':
1114 return each(operandsOf(args), true)
1115 case 'mv':
1116 return copyLike(args, ['-t', '--target-directory', '-S', '--suffix'], true)
1117 case 'cp':
1118 case 'ln':
1119 return copyLike(args, ['-t', '--target-directory', '-S', '--suffix'], false)
1120 case 'install':
1121 return copyLike(args, ['-t', '--target-directory', '-m', '--mode', '-o', '--owner', '-g', '--group', '-S', '--suffix'], false)
1122 case 'rsync':
1123 return copyLike(args, ['-e', '--rsh', '--exclude', '--include'], false)
1124 case 'sed':
1125 case 'gsed':
1126 return sedTargets(args)
1127 case 'perl':
1128 return perlTargets(args)
1129 case 'dd':
1130 return args.filter(current => current.text.startsWith('of=')).map(current => ({ word: { ...current, text: current.text.slice('of='.length) }, mayCreate: true }))
1131 case 'git':
1132 return gitTargets(args)
1133 default:
1134 return []
1135 }
1136}
1137
1138const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
1139/** How deep `bash -c "eval '...'"` is read before giving up. */
1140const MAX_NESTING = 3
1141
1142/** A path a Bash command touches, absolute; `intoDir` set means it counts only when that folder exists. */
1143export type ShellTarget = { path: string; mayCreate: boolean; hasGlob: boolean; intoDir?: string }
1144
1145/** One simple command as typed, and with its wrappers and redirections taken off. */
1146export type ShellSegment = { raw: string; plain: string }
1147
1148export type ShellAnalysis = { segments: ShellSegment[]; targets: ShellTarget[] }
1149
1150/** The commands a Bash call runs and the paths it writes, `cd` followed from `cwd`. */
1151export const analyzeBash = (command: string, cwd: string, home?: string, depth = 0): ShellAnalysis => {
1152 const segments: ShellSegment[] = []
1153 const targets: ShellTarget[] = []
1154 let base = cwd
1155
1156 for (const raw of splitSegments(command)) {
1157 const { argv: words, redirects } = splitRedirects(wordsOf(raw))
1158 const argv = stripWrappers(words)
1159 const name = baseName(argv[0]?.text ?? '')
1160 segments.push({ raw, plain: argv.map(current => current.text).join(' ') })
1161
1162 const place = (current: Word): string | null =>
1163 current.hasVariable || current.text === '' ? null : resolvePath(base, current.text, current.isQuoted ? undefined : home)
1164
1165 for (const { operator, target } of redirects) {
1166 const path = operator.includes('>') && !SPECIAL_FILES.test(target.text) ? place(target) : null
1167
1168 if (path !== null) {
1169 targets.push({ path, mayCreate: true, hasGlob: target.hasGlob })
1170 }
1171 }
1172
1173 if (name === 'cd' || name === 'pushd') {
1174 const to = operandsOf(argv.slice(1))[0]
1175 const next = to === undefined ? (home ?? null) : to.text === '-' ? null : place(to)
1176 base = next ?? base
1177 continue
1178 }
1179
1180 if (depth < MAX_NESTING && SHELLS.has(name)) {
1181 const script = optionValue(argv, ['-c'])
1182
1183 if (script !== undefined) {
1184 const inner = analyzeBash(script.text, base, home, depth + 1)
1185 segments.push(...inner.segments)
1186 targets.push(...inner.targets)
1187 }
1188
1189 continue
1190 }
1191
1192 if (depth < MAX_NESTING && name === 'eval') {
1193 const inner = analyzeBash(argv.slice(1).map(current => current.text).join(' '), base, home, depth + 1)
1194 segments.push(...inner.segments)
1195 targets.push(...inner.targets)
1196 continue
1197 }
1198
1199 for (const target of commandTargets(argv)) {
1200 const path = place(target.word)types/index.d.ts 43 lines1export type ProtectRule = { kind: 'protect'; id: string; glob: string; why: string; source: string }
2
3/** A glob the user wrote, or the built-in report-file test, which matches whole words of the name. */
4export type NewFileMatch = { type: 'glob'; glob: string } | { type: 'report-words' }
5
6export type NoNewFilesRule = { kind: 'noNewFiles'; id: string; match: NewFileMatch; except: string[]; why: string; source: string }
7
8export type CommandRule = { kind: 'commands'; id: string; match: string; why: string; source: string }
9
10export type Rule = ProtectRule | NoNewFilesRule | CommandRule
11
12export type RuleSet = {
13 protect: ProtectRule[]
14 noNewFiles: NoNewFilesRule[]
15 commands: CommandRule[]
16 pin: string[]
17 /** Prompts between two pin injections; null while no file set it. */
18 pinEvery: number | null
19}
20
21export type LoadedRules = {
22 /** File mtimes and settings the rules were read under; a different key means read again. */
23 key: string
24 rules: RuleSet
25 /** The files the rules came from, as /house-rules names them. */
26 files: string[]
27 errors: string[]
28 isDefault: boolean
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'house-rules': {
34 loaded: LoadedRules | null
35 blocks: Record<string, number>
36 prompts: number
37 toasted: string[]
38 /** A compaction ran and the pinned rules have not been re-read since. */
39 pinPending: boolean
40 }
41 }
42}
43