Enforces your standing rules for Claude: no push to main, no force push, no surprise deletes or new dependencies.

A mod for Claude Code that enforces the standing rules you would otherwise paste into every prompt.
"Don't push to main. Don't force push. Don't add dependencies without asking." Written in a prompt, those are requests, and an agent can forget them halfway through a long session. House Rules checks every shell command and file edit at the point where Claude Code decides whether it may run, so the rule holds whether or not Claude remembers it.
It asks no model anything. Every decision comes from rules you can read in hooks/rules.ts.
| Rule | Default | What it covers | | :- | :- | :- | | Push to a protected branch | ask | git push origin main, HEAD:main, and a bare git push while a protected branch is checked out | | Force push | deny | --force, -f, --force-with-lease, and +branch refspecs | | Destructive commands | ask | rm -r, git reset --hard, git clean -f, git checkout -- ., git branch -D, deleting a remote branch, DROP TABLE, and a few others | | New dependencies | ask | npm install <package>, pip install <package>, cargo add and their relatives, plus edits to the dependency sections of a manifest | | Protected paths | ask | Writing .env, .env.*, *.pem or *.key |
Each rule has one of three settings:
A rule only ever tightens. It never approves a call that something else refused.
/plugin install house-rules-guard --marketplace ramankrishna/house-rules-guard
Answer y to add the marketplace, then pick a scope. Needs Claude Code v2.1.287 or later.
The defaults apply as soon as it is installed. There is nothing to set up.
Run /house-rules init, or write .house-rules.json at the project root yourself:
{
"protectedBranches": ["main", "release"],
"rules": {
"pushToProtectedBranch": "deny",
"forcePush": "deny",
"destructiveCommands": "ask",
"newDependencies": "ask",
"protectedPaths": "ask"
},
"protectedPaths": [".env", ".env.*", "*.pem", "*.key", "infra/prod/**"],
"custom": [
{ "match": "terraform apply", "mode": "ask", "why": "applies infrastructure changes" },
{ "match": "/\\bkubectl\\b.*\\bprod\\b/", "mode": "deny", "why": "touches the production cluster" }
]
}
custom rule matches plain text inside the command, or a pattern when written between slashes..house-rules.json itself, whatever the rules say. It cannot quietly loosen the rules it works under./house-rules says what is wrong./house-rules opens a pane with two tabs:
.house-rules.json at the project root..house-rules.json once, only when you run /house-rules init and the file does not exist.git branch --show-current, only when Claude runs a git push that names no branch, to learn which branch would be pushed.Like every mod, it runs with the same access to your machine as Claude Code itself. The full statement is in PRIVACY.md.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.tsx 342 lines1// House Rules: the constraints you would otherwise paste into every prompt,
2// enforced where Claude Code decides whether a tool call may run.
3//
4// A rule set to "ask" puts the call to you with the reason; "deny" refuses it
5// and tells Claude why. Nothing here asks a model anything or leaves the machine.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Entry, Tab } from '../types'
11import {
12 clip,
13 DEFAULTS,
14 judgeBash,
15 judgeEdit,
16 LABELS,
17 needsBranch,
18 parseConfig,
19 reasonOf,
20 relative,
21 RULE_NAMES,
22 RULES_FILE,
23} from './rules'
24import type { Config, Finding } from './rules'
25
26const PANE = 'house-rules'
27
28const log = atom({ plugin: 'house-rules-guard', key: 'log' } as const, [])
29const tab = atom({ plugin: 'house-rules-guard', key: 'tab' } as const, 'rules')
30const problem = atom({ plugin: 'house-rules-guard', key: 'problem' } as const, null)
31
32// What the guards read without a call on `$`: a `.catch` handler may not make
33// one, and a guard that cannot tell must still hold the rules.
34let root = ''
35let config: Config = DEFAULTS
36let hasFile = false
37const asked = new Set<string>()
38
39const field = (input: unknown, name: string): string => {
40 const value = (input as Record<string, unknown> | null)?.[name]
41
42 return typeof value === 'string' ? value : ''
43}
44
45/** Reads `.house-rules.json` at the project root; the defaults stand without one. */
46async function load($: EngineInterface): Promise<void> {
47 root = await $.session.root()
48
49 const path = `${root}/${RULES_FILE}`
50
51 hasFile = await $.fs.exists(path)
52
53 if (!hasFile) {
54 config = DEFAULTS
55 await update($, problem, () => null)
56
57 return
58 }
59
60 let text = ''
61
62 try {
63 text = await $.fs.read(path)
64 } catch {
65 text = ''
66 }
67
68 const parsed = parseConfig(text)
69
70 // A file that does not parse loosens nothing: the defaults hold until it is fixed.
71 config = typeof parsed === 'string' ? DEFAULTS : parsed
72 await update($, problem, () => (typeof parsed === 'string' ? parsed : null))
73}
74
75async function init($: EngineInterface): Promise<string> {
76 root = await $.session.root()
77
78 const path = `${root}/${RULES_FILE}`
79
80 if (await $.fs.exists(path)) return `${RULES_FILE} already exists. Edit it, then run /house-rules.`
81
82 const starter = {
83 protectedBranches: DEFAULTS.protectedBranches,
84 rules: DEFAULTS.rules,
85 protectedPaths: DEFAULTS.protectedPaths,
86 custom: [],
87 }
88
89 await $.fs.write(path, `${JSON.stringify(starter, null, 2)}\n`)
90 await load($)
91
92 return `Wrote ${RULES_FILE} with the default rules. Set each rule to "allow", "ask" or "deny".`
93}
94
95async function currentBranch($: EngineInterface): Promise<string | null> {
96 try {
97 const ran = await $.process.run(['git', 'branch', '--show-current'], { cwd: root, timeoutMs: 5000 })
98 const name = ran.stdout.trim()
99
100 return ran.exitCode === 0 && name !== '' ? name : null
101 } catch {
102 return null
103 }
104}
105
106/** Records that a rule stepped in, and says so when it blocked. */
107async function note(
108 $: EngineInterface,
109 id: string | undefined,
110 finding: Finding,
111 subject: string,
112): Promise<void> {
113 const entry: Entry = {
114 id: id ?? '',
115 rule: finding.rule,
116 mode: finding.mode,
117 what: finding.what,
118 subject: clip(subject.replace(/\s+/g, ' ').trim(), 120),
119 outcome: finding.mode === 'deny' ? 'blocked' : 'asked',
120 }
121
122 if (finding.mode === 'ask' && id !== undefined) asked.add(id)
123 if (finding.mode === 'deny') $.ui.toast(`Blocked: this ${finding.what}.`)
124
125 await update($, log, was => [...was, entry].slice(-50))
126}
127
128const openPane = ($: EngineInterface) =>
129 $.ui.open({ id: PANE, title: 'House Rules', focus: true, closeOnEscape: true })
130
131export const register: Register = on => {
132 on('session.start', async ($, e, next) => {
133 await $.command.register({
134 name: 'house-rules',
135 description: 'Show the rules Claude works under here, and when they stepped in',
136 argumentHint: '[init]',
137 })
138 await load($)
139
140 return next(e)
141 })
142
143 // /clear, /resume and /branch raise no session.start: read the rules again.
144 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
145 await load($)
146
147 return next(e)
148 })
149
150 // An edit you make by hand between turns takes hold on the next one.
151 on('turn.start', async ($, e, next) => {
152 await load($)
153
154 return next(e)
155 })
156
157 on('command.run', { command: 'house-rules' }, async ($, e) => {
158 if (e.args.trim() === 'init') return { text: await init($) }
159
160 await load($)
161 await openPane($)
162
163 return {}
164 })
165
166 // ------------------------------------------------------------- deciding
167
168 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
169 const decided = await next(e)
170 const command = field(e.input, 'command')
171
172 if (command === '') return decided
173
174 const branch = needsBranch(command) ? await currentBranch($) : null
175 const finding = judgeBash(config, command, branch)
176
177 if (finding === null) return decided
178
179 await note($, e.tool_use_id, finding, command)
180
181 // A rule only tightens: a call already refused stays refused.
182 if (finding.mode === 'ask' && decided.decision === 'deny') return decided
183
184 return { decision: finding.mode, reason: reasonOf(finding) }
185 }).catch(($, e, next) => {
186 // The hook failed: judge from what is in memory, the branch unknown.
187 const finding = judgeBash(config, field(e.input, 'command'), null)
188
189 return finding === null ? next(e) : { decision: finding.mode, reason: reasonOf(finding) }
190 })
191
192 on('tool.check', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
193 const decided = await next(e)
194 const path = relative(root, field(e.input, 'file_path'))
195
196 if (path === '') return decided
197
198 const text = `${field(e.input, 'old_string')}\n${field(e.input, 'new_string')}\n${field(e.input, 'content')}`
199 const finding = judgeEdit(config, path, text)
200
201 if (finding === null) return decided
202
203 await note($, e.tool_use_id, finding, path)
204
205 if (finding.mode === 'ask' && decided.decision === 'deny') return decided
206
207 return { decision: finding.mode, reason: reasonOf(finding) }
208 }).catch(($, e, next) => {
209 const path = relative(root, field(e.input, 'file_path'))
210 const text = `${field(e.input, 'old_string')}\n${field(e.input, 'new_string')}\n${field(e.input, 'content')}`
211 const finding = judgeEdit(config, path, text)
212
213 return finding === null ? next(e) : { decision: finding.mode, reason: reasonOf(finding) }
214 })
215
216 // How a call you were asked about ended: whether it ran or you refused it.
217 on('tool.call', { tool: ['Bash', 'Edit', 'Write'] }, async ($, e, next) => {
218 const ran = await next(e)
219
220 if (!asked.has(e.tool_use_id)) return ran
221
222 asked.delete(e.tool_use_id)
223
224 const outcome = ran.deny === undefined ? 'ran' : 'refused'
225
226 await update($, log, was =>
227 was.map(entry =>
228 entry.id === e.tool_use_id && entry.outcome === 'asked' ? { ...entry, outcome } : entry,
229 ),
230 )
231
232 if (ran.deny === undefined && relative(root, 'file_path' in e ? e.file_path : '') === RULES_FILE) {
233 await load($)
234 }
235
236 return ran
237 })
238
239 // -------------------------------------------------------------- drawing
240
241 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
242 const { Box, Button, Text } = $.ui.resolve(e)
243 const entries = await read($, log)
244 const shown = await read($, tab)
245 const wrong = await read($, problem)
246 const width = Math.max(24, e.props.bodyColumns - 10)
247
248 const tabButton = (to: Tab, label: string, hotkey: string) => (
249 <Button
250 key={`tab-${to}`}
251 label={label}
252 hotkey={hotkey}
253 plain
254 dimColor={shown !== to}
255 onPress={() => update($, tab, () => to)}
256 />
257 )
258
259 const modeText = (mode: string) =>
260 mode === 'deny' ? (
261 <Text color="error">DENY</Text>
262 ) : mode === 'ask' ? (
263 <Text color="warning">ASK</Text>
264 ) : (
265 <Text dimColor>ALLOW</Text>
266 )
267
268 const rules = (
269 <Box flexDirection="column" rowGap={1}>
270 {wrong !== null && (
271 <Text color="error">
272 {RULES_FILE} is not usable ({wrong}). The default rules are in force.
273 </Text>
274 )}
275 <Box flexDirection="column">
276 {RULE_NAMES.map(name => (
277 <Box flexDirection="row" columnGap={2}>
278 <Box width={6} flexShrink={0}>
279 {modeText(config.rules[name])}
280 </Box>
281 <Text>{LABELS[name]}</Text>
282 </Box>
283 ))}
284 {config.custom.map(rule => (
285 <Box flexDirection="row" columnGap={2}>
286 <Box width={6} flexShrink={0}>
287 {modeText(rule.mode)}
288 </Box>
289 <Text>{clip(rule.why, width)}</Text>
290 </Box>
291 ))}
292 </Box>
293 <Box flexDirection="column">
294 <Text dimColor>Protected branches: {clip(config.protectedBranches.join(', ') || 'none', width)}</Text>
295 <Text dimColor>Protected paths: {clip(config.protectedPaths.join(', ') || 'none', width)}</Text>
296 </Box>
297 <Text dimColor>
298 {hasFile
299 ? `From ${RULES_FILE}. Claude must ask before changing it.`
300 : `These are the defaults. /house-rules init writes ${RULES_FILE} so you can change them.`}
301 </Text>
302 </Box>
303 )
304
305 const history = (
306 <Box flexDirection="column" rowGap={1}>
307 {entries.length === 0 && <Text dimColor>No rule has stepped in this session.</Text>}
308 {entries
309 .slice(-10)
310 .reverse()
311 .map(entry => (
312 <Box flexDirection="column">
313 <Box flexDirection="row" columnGap={2}>
314 {entry.outcome === 'blocked' ? (
315 <Text color="error">BLOCKED</Text>
316 ) : entry.outcome === 'refused' ? (
317 <Text color="warning">REFUSED</Text>
318 ) : entry.outcome === 'ran' ? (
319 <Text color="success">ALLOWED</Text>
320 ) : (
321 <Text dimColor>ASKED</Text>
322 )}
323 <Text>{clip(entry.what, width)}</Text>
324 </Box>
325 <Text dimColor>{clip(entry.subject, width + 8)}</Text>
326 </Box>
327 ))}
328 </Box>
329 )
330
331 return (
332 <Box flexDirection="column" rowGap={1}>
333 <Box flexDirection="row" columnGap={3}>
334 {tabButton('rules', 'Rules', '1')}
335 {tabButton('log', `Log (${entries.length})`, '2')}
336 </Box>
337 {shown === 'rules' ? rules : history}
338 </Box>
339 )
340 })
341}
342hooks/rules.ts 529 lines1// The rules themselves, with no engine in them: every function here is pure,
2// so the tests and the hooks read the same judgement.
3
4export const RULES_FILE = '.house-rules.json'
5
6export type Mode = 'allow' | 'ask' | 'deny'
7
8export type RuleName =
9 | 'pushToProtectedBranch'
10 | 'forcePush'
11 | 'destructiveCommands'
12 | 'newDependencies'
13 | 'protectedPaths'
14
15export const RULE_NAMES: readonly RuleName[] = [
16 'pushToProtectedBranch',
17 'forcePush',
18 'destructiveCommands',
19 'newDependencies',
20 'protectedPaths',
21]
22
23export type CustomRule = { match: string; mode: Mode; why: string }
24
25export type Config = {
26 protectedBranches: string[]
27 rules: Record<RuleName, Mode>
28 protectedPaths: string[]
29 custom: CustomRule[]
30}
31
32export const DEFAULTS: Config = {
33 protectedBranches: ['main', 'master'],
34 rules: {
35 pushToProtectedBranch: 'ask',
36 forcePush: 'deny',
37 destructiveCommands: 'ask',
38 newDependencies: 'ask',
39 protectedPaths: 'ask',
40 },
41 protectedPaths: ['.env', '.env.*', '*.pem', '*.key'],
42 custom: [],
43}
44
45/** One rule a call ran into: which, how strictly, and what the call does. */
46export type Finding = {
47 rule: RuleName | 'custom' | 'rulesFile'
48 mode: 'ask' | 'deny'
49 /** Completes "this ...": `pushes to main, a protected branch`. */
50 what: string
51}
52
53// ---------------------------------------------------------------- config
54
55const isRecord = (value: unknown): value is Record<string, unknown> =>
56 typeof value === 'object' && value !== null && !Array.isArray(value)
57
58const isStrings = (value: unknown): value is string[] =>
59 Array.isArray(value) && value.every(one => typeof one === 'string')
60
61const isMode = (value: unknown): value is Mode =>
62 value === 'allow' || value === 'ask' || value === 'deny'
63
64/** Reads `.house-rules.json` over the defaults; answers one line when it is wrong. */
65export function parseConfig(text: string): Config | string {
66 let data: unknown
67
68 try {
69 data = JSON.parse(text)
70 } catch {
71 return 'not valid JSON'
72 }
73
74 if (!isRecord(data)) return 'expected an object'
75
76 const config: Config = {
77 protectedBranches: [...DEFAULTS.protectedBranches],
78 rules: { ...DEFAULTS.rules },
79 protectedPaths: [...DEFAULTS.protectedPaths],
80 custom: [],
81 }
82
83 if (data.protectedBranches !== undefined) {
84 if (!isStrings(data.protectedBranches)) return '"protectedBranches" must be a list of names'
85 config.protectedBranches = data.protectedBranches
86 }
87 if (data.protectedPaths !== undefined) {
88 if (!isStrings(data.protectedPaths)) return '"protectedPaths" must be a list of patterns'
89 config.protectedPaths = data.protectedPaths
90 }
91 if (data.rules !== undefined) {
92 if (!isRecord(data.rules)) return '"rules" must be an object'
93
94 for (const [name, mode] of Object.entries(data.rules)) {
95 if (!RULE_NAMES.includes(name as RuleName)) return `"${name}" is not a rule`
96 if (!isMode(mode)) return `rule ${name} must be "allow", "ask" or "deny"`
97
98 config.rules[name as RuleName] = mode
99 }
100 }
101 if (data.custom !== undefined) {
102 if (!Array.isArray(data.custom)) return '"custom" must be a list'
103
104 for (const raw of data.custom) {
105 if (!isRecord(raw) || typeof raw.match !== 'string' || raw.match === '') {
106 return 'each custom rule needs a "match"'
107 }
108 if (!isMode(raw.mode)) return `custom rule "${raw.match}" needs a mode: "allow", "ask" or "deny"`
109
110 const regex = regexOf(raw.match)
111
112 if (regex === 'invalid') return `custom rule "${raw.match}" is not a valid pattern`
113
114 config.custom.push({
115 match: raw.match,
116 mode: raw.mode,
117 why: typeof raw.why === 'string' && raw.why !== '' ? raw.why : `matches "${raw.match}"`,
118 })
119 }
120 }
121
122 return config
123}
124
125/** A custom match written `/like this/i` is a pattern; anything else is literal text. */
126function regexOf(match: string): RegExp | null | 'invalid' {
127 const found = /^\/(.+)\/([a-z]*)$/.exec(match)
128
129 if (found === null) return null
130
131 try {
132 return new RegExp(found[1] ?? '', found[2] ?? '')
133 } catch {
134 return 'invalid'
135 }
136}
137
138// --------------------------------------------------------------- parsing
139
140export const squash = (text: string): string => text.replace(/\s+/g, ' ').trim()
141
142/** The commands a shell line holds, each with wrappers such as `sudo` taken off. */
143function segmentsOf(command: string): string[] {
144 return command
145 .split(/&&|\|\||[;|\n]/)
146 .map(part =>
147 squash(part)
148 .replace(/^[({]\s*/, '')
149 .replace(/^((sudo|time|nohup|command)\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, ''),
150 )
151 .filter(part => part !== '')
152}
153
154function tokensOf(segment: string): string[] {
155 return (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(token =>
156 token.replace(/^(["'])(.*)\1$/, '$2'),
157 )
158}
159
160/** A git command's subcommand and its arguments, global options skipped. */
161function gitOf(tokens: readonly string[]): { sub: string; args: string[] } | null {
162 if (tokens[0] !== 'git') return null
163
164 let at = 1
165
166 while (at < tokens.length) {
167 const token = tokens[at] ?? ''
168
169 if (token === '-C' || token === '-c') at += 2
170 else if (token.startsWith('-')) at += 1
171 else break
172 }
173
174 const sub = tokens[at]
175
176 return sub === undefined ? null : { sub, args: tokens.slice(at + 1) }
177}
178
179const VALUE_FLAGS = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
180
181type Push = {
182 isForce: boolean
183 isDelete: boolean
184 /** Branch names the push writes; `null` stands for the current branch. */
185 targets: Array<string | null>
186 isEverything: boolean
187}
188
189function pushOf(args: readonly string[]): Push {
190 const flags: string[] = []
191 const positional: string[] = []
192
193 for (let at = 0; at < args.length; at += 1) {
194 const arg = args[at] ?? ''
195
196 if (VALUE_FLAGS.has(arg)) at += 1
197 else if (arg.startsWith('-')) flags.push(arg)
198 else positional.push(arg)
199 }
200
201 const refspecs = positional.slice(1)
202 const isShortForce = (flag: string): boolean => /^-[a-zA-Z]*f[a-zA-Z]*$/.test(flag)
203 const isEverything = flags.some(flag => ['--all', '--mirror', '--branches'].includes(flag))
204 const isTagsOnly = flags.includes('--tags') && refspecs.length === 0
205
206 const targets = refspecs.map((refspec): string | null => {
207 const plain = refspec.replace(/^\+/, '')
208 const target = (plain.includes(':') ? (plain.split(':').pop() ?? '') : plain).replace(
209 /^refs\/heads\//,
210 '',
211 )
212
213 return target === '' || target === 'HEAD' || target === '@' ? null : target
214 })
215
216 return {
217 isForce:
218 flags.some(
219 flag =>
220 flag === '--force' ||
221 flag.startsWith('--force-with-lease') ||
222 flag === '--force-if-includes' ||
223 isShortForce(flag),
224 ) || refspecs.some(refspec => refspec.startsWith('+')),
225 isDelete:
226 flags.some(flag => flag === '--delete' || /^-[a-zA-Z]*d[a-zA-Z]*$/.test(flag)) ||
227 refspecs.some(refspec => refspec.startsWith(':')),
228 targets: refspecs.length > 0 || isEverything || isTagsOnly ? targets : [null],
229 isEverything,
230 }
231}
232
233/** True when judging the command needs the branch that is checked out. */
234export function needsBranch(command: string): boolean {
235 return segmentsOf(command).some(segment => {
236 const git = gitOf(tokensOf(segment))
237
238 return git?.sub === 'push' && pushOf(git.args).targets.includes(null)
239 })
240}
241
242// ------------------------------------------------------------ the rules
243
244const DESTRUCTIVE: ReadonlyArray<readonly [RegExp, string]> = [
245 [/^rm\s+(.*\s)?-[a-zA-Z]*[rR][a-zA-Z]*(\s|$)|^rm\s+(.*\s)?--recursive\b/, 'deletes a folder tree'],
246 [/^git\s+(.*\s)?reset\s+(.*\s)?--hard\b/, 'discards uncommitted work (git reset --hard)'],
247 [/^git\s+(.*\s)?clean\s+(.*\s)?-[a-zA-Z]*f/, 'deletes untracked files (git clean)'],
248 [/^git\s+(.*\s)?(checkout|restore)\s+(--\s+)?\.(\s|$)/, 'discards every uncommitted change'],
249 [/^git\s+(.*\s)?branch\s+(.*\s)?(-D\b|--delete\s+--force\b)/, 'force-deletes a branch'],
250 [/^git\s+(.*\s)?stash\s+(drop|clear)\b/, 'drops stashed work'],
251 [/^find\s+.*\s-delete\b/, 'deletes every file a search finds'],
252 [/\b(drop\s+(table|database|schema)|truncate\s+table)\b/i, 'drops data from a database'],
253 [/^terraform\s+(.*\s)?destroy\b/, 'destroys infrastructure (terraform destroy)'],
254 [/^kubectl\s+(.*\s)?delete\b/, 'deletes cluster resources (kubectl delete)'],
255 [/^docker\s+(system|volume)\s+(prune|rm)\b/, 'removes docker data'],
256]
257
258const LOCAL = /^(\.|\.\/.*|\.\.\/.*|\/.*|.*\.(whl|tar\.gz|tgz|zip))$/
259
260/** The packages an install command names, as typed; empty when it names none. */
261function packagesOf(tokens: readonly string[]): string[] {
262 const [tool = '', sub = '', third = ''] = tokens
263 const named = (from: number, skipAfter: readonly string[] = []): string[] => {
264 const names: string[] = []
265
266 for (let at = from; at < tokens.length; at += 1) {
267 const token = tokens[at] ?? ''
268
269 if (skipAfter.includes(token)) at += 1
270 else if (!token.startsWith('-') && !LOCAL.test(token)) names.push(token)
271 }
272
273 return names
274 }
275 const pipSkips = ['-r', '--requirement', '-c', '--constraint', '-e', '--editable', '--target', '-t']
276
277 switch (tool) {
278 case 'npm':
279 return ['install', 'i', 'add'].includes(sub) ? named(2) : []
280 case 'pnpm':
281 case 'bun':
282 return ['add', 'install', 'i'].includes(sub) ? named(2) : []
283 case 'yarn':
284 return sub === 'add' ? named(2) : []
285 case 'pip':
286 case 'pip3':
287 return sub === 'install' ? named(2, pipSkips) : []
288 case 'python':
289 case 'python3':
290 return sub === '-m' && third === 'pip' && tokens[3] === 'install' ? named(4, pipSkips) : []
291 case 'uv':
292 if (sub === 'add') return named(2)
293
294 return sub === 'pip' && third === 'install' ? named(3, pipSkips) : []
295 case 'poetry':
296 case 'bundle':
297 return sub === 'add' ? named(2) : []
298 case 'pipenv':
299 case 'conda':
300 case 'gem':
301 case 'brew':
302 case 'apt':
303 case 'apt-get':
304 return sub === 'install' ? named(2) : []
305 case 'cargo':
306 return sub === 'add' || sub === 'install' ? named(2) : []
307 case 'go':
308 return sub === 'get' || sub === 'install' ? named(2) : []
309 case 'composer':
310 return sub === 'require' ? named(2) : []
311 default:
312 return []
313 }
314}
315
316function globOf(pattern: string): RegExp {
317 const body = pattern
318 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
319 .replace(/\*\*/g, '\u0000')
320 .replace(/\*/g, '[^/]*')
321 .replace(/\u0000/g, '.*')
322
323 return new RegExp(`^${body}$`)
324}
325
326/** Whether a path, relative to the project, matches one of the patterns. */
327export function matchesPath(patterns: readonly string[], path: string): boolean {
328 const clean = path.replace(/^\.\//, '')
329 const base = clean.split('/').pop() ?? clean
330
331 return patterns.some(pattern =>
332 pattern.includes('/') ? globOf(pattern).test(clean) : globOf(pattern).test(base),
333 )
334}
335
336const WRITES =
337 /^(sed\s+[^|;&]*-i|tee\b|mv\b|rm\b|cp\b|truncate\b|patch\b|chmod\b|git\s+(checkout|restore|apply)\b)/
338
339/** The protected path a shell command looks set to write, or null. */
340function protectedWrite(patterns: readonly string[], segment: string): string | null {
341 const tokens = tokensOf(segment)
342
343 for (let at = 0; at < tokens.length; at += 1) {
344 const token = tokens[at] ?? ''
345 const isRedirect = /^\d?>{1,2}/.test(token) || /^\d?>{1,2}$/.test(tokens[at - 1] ?? '')
346 const path = token.replace(/^\d?>{1,2}/, '')
347
348 if (path === '' || path.startsWith('-') || !matchesPath(patterns, path)) continue
349 if (isRedirect || WRITES.test(segment)) return path
350 }
351
352 return null
353}
354
355const strictest = (findings: readonly Finding[]): Finding | null =>
356 findings.find(one => one.mode === 'deny') ?? findings[0] ?? null
357
358function found(config: Config, rule: RuleName, what: string): Finding[] {
359 const mode = config.rules[rule]
360
361 return mode === 'allow' ? [] : [{ rule, mode, what }]
362}
363
364/**
365 * Judges a shell command: the strictest rule it runs into, or null.
366 *
367 * `branch` is the branch checked out, asked for only when `needsBranch` says
368 * so; null where it could not be read, which counts as protected.
369 */
370export function judgeBash(config: Config, command: string, branch: string | null): Finding | null {
371 const findings: Finding[] = []
372 const flat = squash(command)
373
374 for (const custom of config.custom) {
375 const regex = regexOf(custom.match)
376 const isHit = regex instanceof RegExp ? regex.test(flat) : flat.includes(custom.match)
377
378 if (isHit && custom.mode !== 'allow') {
379 findings.push({ rule: 'custom', mode: custom.mode, what: custom.why })
380 }
381 }
382
383 for (const segment of segmentsOf(command)) {
384 const tokens = tokensOf(segment)
385 const git = gitOf(tokens)
386
387 if (git?.sub === 'push') {
388 const push = pushOf(git.args)
389
390 if (push.isForce) findings.push(...found(config, 'forcePush', 'is a force push'))
391 if (push.isDelete) {
392 findings.push(...found(config, 'destructiveCommands', 'deletes a remote branch'))
393 }
394
395 const isUnknown = push.targets.includes(null) && branch === null
396 const hit = push.targets
397 .map(target => target ?? branch)
398 .find(target => target !== null && config.protectedBranches.includes(target))
399
400 if (push.isEverything) {
401 findings.push(...found(config, 'pushToProtectedBranch', 'pushes every branch'))
402 } else if (hit !== undefined && hit !== null) {
403 findings.push(
404 ...found(config, 'pushToProtectedBranch', `pushes to ${hit}, a protected branch`),
405 )
406 } else if (isUnknown) {
407 findings.push(
408 ...found(
409 config,
410 'pushToProtectedBranch',
411 'pushes the current branch, which could not be read and may be protected',
412 ),
413 )
414 }
415 }
416
417 for (const [pattern, what] of DESTRUCTIVE) {
418 if (pattern.test(segment)) {
419 findings.push(...found(config, 'destructiveCommands', what))
420 break
421 }
422 }
423
424 const packages = packagesOf(tokens)
425
426 if (packages.length > 0) {
427 const shown = packages.slice(0, 3).join(', ') + (packages.length > 3 ? ', ...' : '')
428
429 findings.push(...found(config, 'newDependencies', `installs ${shown}`))
430 }
431
432 const rulesFile = protectedWrite([RULES_FILE], segment)
433
434 if (rulesFile !== null) {
435 findings.push({ rule: 'rulesFile', mode: 'ask', what: `changes ${RULES_FILE}, the rules themselves` })
436 }
437
438 const path = protectedWrite(config.protectedPaths, segment)
439
440 if (path !== null) {
441 findings.push(...found(config, 'protectedPaths', `writes ${path}, a protected path`))
442 }
443 }
444
445 return strictest(findings)
446}
447
448const ALWAYS_MANIFESTS =
449 /^(requirements[\w.-]*\.txt|Cargo\.toml|go\.mod|Gemfile|Pipfile|pom\.xml|build\.gradle(\.kts)?)$/
450
451const JSON_DEPENDENCY =
452 /"(dependencies|devDependencies|peerDependencies|optionalDependencies|require|require-dev)"/
453
454const TOML_DEPENDENCY =
455 /dependenc|requires\s*=|["'][A-Za-z][\w.\-[\]]*\s*(==|>=|<=|~=|!=|>|<)\s*\d/
456
457/** Whether an edit's text reads as a change to what a manifest depends on. */
458function touchesDependencies(base: string, text: string): boolean {
459 if (ALWAYS_MANIFESTS.test(base)) return true
460 if (base === 'pyproject.toml') return TOML_DEPENDENCY.test(text)
461 if (base !== 'package.json' && base !== 'composer.json') return false
462 if (JSON_DEPENDENCY.test(text)) return true
463
464 const pairs = text.matchAll(
465 /"([@\w][\w@./-]*)"\s*:\s*"(\^|~|>=|<=|>|<|\*|\d+\.|workspace:|npm:|github:|file:|link:|latest)/g,
466 )
467
468 return [...pairs].some(pair => pair[1] !== 'version')
469}
470
471/**
472 * Judges a file edit: the strictest rule it runs into, or null.
473 *
474 * `path` is relative to the project; `text` is what the edit removes and adds.
475 */
476export function judgeEdit(config: Config, path: string, text: string): Finding | null {
477 const findings: Finding[] = []
478 const clean = path.replace(/^\.\//, '')
479 const base = clean.split('/').pop() ?? clean
480
481 if (clean === RULES_FILE) {
482 findings.push({ rule: 'rulesFile', mode: 'ask', what: `changes ${RULES_FILE}, the rules themselves` })
483 }
484 if (matchesPath(config.protectedPaths, clean)) {
485 findings.push(...found(config, 'protectedPaths', `writes ${clean}, a protected path`))
486 }
487 if (touchesDependencies(base, text)) {
488 findings.push(...found(config, 'newDependencies', `changes the dependencies in ${clean}`))
489 }
490
491 return strictest(findings)
492}
493
494// ------------------------------------------------------------------ words
495
496const ADVICE: Record<Finding['rule'], string> = {
497 pushToProtectedBranch: 'Push to a feature branch and open a pull request instead.',
498 forcePush: 'Push to a new branch instead.',
499 destructiveCommands: 'Find a way that does not delete work, or ask the user to do it.',
500 newDependencies: 'Use what is already installed, or ask the user to add it.',
501 protectedPaths: 'Leave that file as it is and tell the user what you needed from it.',
502 custom: 'Ask the user before trying another way.',
503 rulesFile: 'Ask the user to change the rules themselves.',
504}
505
506/** What the dialog shows on an ask, and what the model reads on a deny. */
507export function reasonOf(finding: Finding): string {
508 return finding.mode === 'ask'
509 ? `House Rules: this ${finding.what}.`
510 : `House Rules: this ${finding.what}, which is not allowed in this project. ${ADVICE[finding.rule]}`
511}
512
513export const LABELS: Record<RuleName, string> = {
514 pushToProtectedBranch: 'Push to a protected branch',
515 forcePush: 'Force push',
516 destructiveCommands: 'Destructive commands',
517 newDependencies: 'New dependencies',
518 protectedPaths: 'Protected paths',
519}
520
521export function relative(root: string, path: string): string {
522 const base = root.endsWith('/') ? root : `${root}/`
523
524 return root !== '' && path.startsWith(base) ? path.slice(base.length) : path
525}
526
527export const clip = (text: string, width: number): string =>
528 text.length > width ? `${text.slice(0, Math.max(1, width - 3))}...` : text
529types/index.d.ts 22 lines1/** One time a rule stepped in, and how it ended. */
2export type Entry = {
3 /** The tool call's id. */
4 id: string
5 rule: string
6 mode: 'ask' | 'deny'
7 /** What the call does, completing "this ...". */
8 what: string
9 /** The command or the path, cut short. */
10 subject: string
11 /** `asked` until the call settles, then whether it ran. */
12 outcome: 'blocked' | 'asked' | 'ran' | 'refused'
13}
14
15export type Tab = 'rules' | 'log'
16
17declare module 'claude-code' {
18 interface PluginState {
19 'house-rules-guard': { log: Entry[]; tab: Tab; problem: string | null }
20 }
21}
22