SLOPSHOPPER

House Rules

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

newpaneguardcommandtoastprocess
v0.1.0MITupdated 2026-10-09ramankrishna/house-rules-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · house-rules-guard
│ ┃ House Rules ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Rules 2: Log (0) │ ┃ ⏺ Read(src/auth.ts) │ ┃ ASK Push to a protected branch ⎿ Read 6 lines │ ┃ DENY Force push ⏺ Update(src/auth.ts) │ ┃ ASK Destructive commands ⎿ Added 2 lines, removed 1 line │ ┃ ASK New dependencies ⏺ Bash(bun test) │ ┃ ASK Protected paths ⎿ 3 pass, 1 fail │ ┃ │ ┃ Protected branches: main, master ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Protected paths: .env, .env.*, *.pem, *.key │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ These are the defaults. /house-rules init │ ┃ writes .house-rules.json so you can change › /house-rules │ ┃ them. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · House Rules
1: Rules 2: Log (0) ASK Push to a protected branch DENY Force push ASK Destructive commands ASK New dependencies ASK Protected paths Protected branches: main, master Protected paths: .env, .env.*, *.pem, *.key These are the defaults. /house-rules init writes .house-rules.json so you can change them.
README

House Rules

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.

The rules

| 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:

  • ask: Claude Code puts the call to you, with the reason shown.
  • deny: the call is refused, and Claude is told why and what to do instead.
  • allow: the rule is off.

A rule only ever tightens. It never approves a call that something else refused.

Install

/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.

Change the rules

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" }
  ]
}
  • Anything you leave out keeps its default.
  • A custom rule matches plain text inside the command, or a pattern when written between slashes.
  • Claude must ask before changing .house-rules.json itself, whatever the rules say. It cannot quietly loosen the rules it works under.
  • If the file does not parse, the defaults stay in force and /house-rules says what is wrong.

See what happened

/house-rules opens a pane with two tabs:

  • Rules: every rule and its setting.
  • Log: each time a rule stepped in this session, and whether the call was blocked, allowed by you, or refused by you.

What it runs, reads and stores

  • Reads .house-rules.json at the project root.
  • Writes .house-rules.json once, only when you run /house-rules init and the file does not exist.
  • Runs git branch --show-current, only when Claude runs a git push that names no branch, to learn which branch would be pushed.
  • Stores nothing between sessions. The log lives in the session and ends with it.
  • Sends nothing. No network calls, no model calls, no telemetry.

Like every mod, it runs with the same access to your machine as Claude Code itself. The full statement is in PRIVACY.md.

Limits

  • "Ask" follows your permission mode. In a mode where Claude Code does not show permission dialogs, an ask is settled by that mode and not by you. Use "deny" for any rule that must hold everywhere.
  • It reads commands, it does not run them. A rule matches how a command is written. A script that force pushes from inside, or a command built up in a variable, will get past it. Protect important branches on your git host as well.
  • Reading is not covered. The protected paths rule stops writes. It does not stop Claude from reading a file.
  • The dependency rule is a heuristic. It recognises the common package managers and manifest files, not every one.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 3 files
hooks/register.tsx 342 lines
1// 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}
342
hooks/rules.ts 529 lines
1// 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
529
types/index.d.ts 22 lines
1/** 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