Iris repo guards: contract-drift warnings, a pre-push check gate, and a production-command guard.

A Claude Code mod (function-hooks plugin) that enforces three repo rules automatically. Claude Code loads it from .claude/skills/iris-guards/ in any session opened in this repo; nothing to install.
| Guard | Hook | What it does |
|---|---|---|
| Contract drift | tool.call on Edit/Write, turn.complete | After an edit to iris-api/openapi.yaml, internal/domain/{types,enums}.go or apps/web/src/types/*.ts, compares everything this branch changed (vs origin/main) and tells Claude which side of the contract-sync rule is missing; toasts a summary at the end of the turn. |
| Pre-push gate | tool.call on Bash | On git push, runs the checks for each surface the branch changed (gofmt -l, go vet, go test in iris-api/; npm run lint, npm test, npm run build in apps/web/) and refuses the push with the failing output. Docs-only pushes run nothing. |
| Production guard | tool.check on Bash | make deploy/ssh/logs/db-pull, ssh/scp/rsync, docker compose -f docker-compose.prod.yml, git reset --hard, force pushes, pushes to main and gh workflow run deploy are denied unless the user's latest prompt asks for that kind of action; when it does, the command still needs approval. |
The rules live in hooks/rules.ts (pure functions); hooks/register.ts wires them to the engine.
claude plugin validate .claude/skills/iris-guards
claude plugin test .claude/skills/iris-guardshooks/register.ts 162 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { checksFor, contractDrift, isContractFile, isGitPush, prodRulesFor, tail } from './rules'
4
5const CHECK_TIMEOUT_MS = 10 * 60 * 1000
6
7type Git = { root: string; lines: (args: string[]) => Promise<string[]> }
8
9/** The repository the session works in, or undefined outside a git checkout. */
10const openGit = async ($: EngineInterface): Promise<Git | undefined> => {
11 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
12 if (top.exitCode !== 0) return undefined
13
14 const root = top.stdout.trim()
15 const lines = async (args: string[]) => {
16 const ran = await $.process.run(['git', '-C', root, ...args])
17 return ran.exitCode === 0 ? ran.stdout.split('\n').filter(Boolean) : []
18 }
19
20 return { root, lines }
21}
22
23/** Where this branch forked from main: origin/main's merge base, else HEAD. */
24const baseOf = async (git: Git): Promise<string> => {
25 const [base] = await git.lines(['merge-base', 'HEAD', 'origin/main'])
26 return base ?? 'HEAD'
27}
28
29/** Every path this branch changes, committed or not, untracked included. */
30const branchChanges = async (git: Git): Promise<string[]> => {
31 const base = await baseOf(git)
32 const diffed = await git.lines(['diff', '--name-only', base])
33 const untracked = await git.lines(['ls-files', '--others', '--exclude-standard'])
34 return [...new Set([...diffed, ...untracked])]
35}
36
37const relativeTo = (root: string, path: string): string =>
38 path.startsWith(root + '/') ? path.slice(root.length + 1) : path
39
40const driftNote = (gaps: string[]): string =>
41 [
42 'iris-guards: the contract-sync rule (CLAUDE.md) looks unbalanced on this branch:',
43 ...gaps.map(gap => `- ${gap}`),
44 'Apply the shape change across openapi.yaml, domain/types.go, the store, apps/web/src/types/ and fixtures, or say why one surface is deliberately untouched.',
45 ].join('\n')
46
47// Set when a contract file is edited, so turn.complete re-checks only then.
48let touchedContract = false
49// The person's latest prompt, which the production guard reads for consent.
50let lastPrompt = ''
51
52type ToolCallAnswer = Awaited<ReturnType<EngineInterface['tool']['call']>>
53
54/** An edit's result, with a drift note for the model when it left a gap. */
55const afterEdit = async ($: EngineInterface, filePath: string, ran: ToolCallAnswer): Promise<ToolCallAnswer> => {
56 if (ran.deny !== undefined || ran.isError === true) return ran
57
58 const git = await openGit($)
59 if (git === undefined || !isContractFile(relativeTo(git.root, filePath))) return ran
60
61 touchedContract = true
62 const gaps = contractDrift(await branchChanges(git))
63 if (gaps.length === 0) return ran
64
65 return { ...ran, context: [...(ran.context ?? []), driftNote(gaps)] }
66}
67
68export const register: Register = on => {
69
70 on('prompt.submit', ($, e, next) => {
71 if (!e.isMeta && e.command === undefined) {
72 lastPrompt = e.text
73 touchedContract = false
74 }
75 return next(e)
76 })
77
78 // 1. Contract drift: after an edit to a contract file, tell the model what
79 // the other surfaces are missing.
80 on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, e.file_path, await next(e)))
81 .catch(($, e, next) => next(e))
82 on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, e.file_path, await next(e)))
83 .catch(($, e, next) => next(e))
84
85 on('turn.complete', async ($, e, next) => {
86 const done = await next(e)
87 if (e.agentId !== undefined || !touchedContract) return done
88
89 touchedContract = false
90 const git = await openGit($)
91 const gaps = git === undefined ? [] : contractDrift(await branchChanges(git))
92 if (gaps.length > 0) {
93 $.ui.toast(`Contract drift: ${gaps.length} surface gap${gaps.length === 1 ? '' : 's'} (see openapi/domain/web types)`)
94 }
95 return done
96 })
97
98 // 2. Pre-push gate: run the changed surfaces' checks; a failure stops the push.
99 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
100 if (!isGitPush(e.command)) return next(e)
101
102 const git = await openGit($)
103 if (git === undefined) return next(e)
104
105 const base = await baseOf(git)
106 const changed = await git.lines(['diff', '--name-only', base, 'HEAD'])
107 const failures: string[] = []
108
109 for (const check of checksFor(changed)) {
110 $.ui.status(`iris-guards: ${check.label} (${check.cwd})`)
111 const cwd = `${git.root}/${check.cwd}`
112 try {
113 const ran = await $.process.run(check.argv, { cwd, timeoutMs: CHECK_TIMEOUT_MS })
114 // gofmt -l exits 0 and lists unformatted files instead of failing.
115 const listsFiles = check.argv[0] === 'gofmt' && ran.stdout.trim() !== ''
116 if (ran.exitCode !== 0 || listsFiles) {
117 failures.push(`## ${check.label} in ${check.cwd} (exit ${ran.exitCode})\n${tail(ran.stdout + '\n' + ran.stderr)}`)
118 }
119 } catch (error) {
120 failures.push(`## ${check.label} in ${check.cwd} could not run: ${String(error)}`)
121 }
122 }
123 $.ui.status(undefined)
124
125 if (failures.length > 0) {
126 return {
127 deny: [
128 'iris-guards blocked this push: there is no CI and main auto-deploys, so the changed surfaces must pass their checks first.',
129 ...failures,
130 'Fix these (run `npm install` in apps/web if dependencies are missing), then push again.',
131 ].join('\n\n'),
132 }
133 }
134 return next(e)
135 }).catch(($, e, next) =>
136 next.called ? next(e) : { deny: 'iris-guards: the pre-push check gate failed to run; run the per-surface checks by hand.' },
137 )
138
139 // 3. Production guard: commands that reach the server or discard work need
140 // the person's own ask in their latest prompt, and then their approval.
141 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
142 const command = (e.input as { command?: unknown }).command
143 if (typeof command !== 'string') return next(e)
144
145 const rules = prodRulesFor(command)
146 if (rules.length === 0) return next(e)
147
148 const unasked = rules.filter(rule => !rule.askedFor.test(lastPrompt))
149 if (unasked.length > 0) {
150 return {
151 decision: 'deny',
152 reason: `iris-guards: this is ${unasked.map(rule => rule.name).join('; ')}, and the user has not asked for it. Ask the user before running it.`,
153 }
154 }
155
156 const verdict = await next(e)
157 return verdict.decision === 'deny'
158 ? verdict
159 : { decision: 'ask', reason: `iris-guards: ${rules.map(rule => rule.name).join('; ')}. Confirm it should run.` }
160 }).catch(() => ({ decision: 'deny', reason: 'iris-guards: the production guard failed; refusing to be safe.' }))
161}
162hooks/rules.ts 130 lines1// Pure rules for the Iris guards: no `$`, so the tests can call them directly.
2
3// --- Contract drift -------------------------------------------------------
4
5const OPENAPI = 'iris-api/openapi.yaml'
6const GO_DOMAIN = /^iris-api\/internal\/domain\/(types|enums)\.go$/
7const GO_STORE = /^iris-api\/internal\/store\/.+\.go$/
8const WEB_TYPES = /^apps\/web\/src\/types\/[^/]+\.ts$/
9
10/** True when a repo-relative path is one of the shared contract files. */
11export const isContractFile = (path: string): boolean =>
12 path === OPENAPI || GO_DOMAIN.test(path) || WEB_TYPES.test(path)
13
14/**
15 * The contract-sync gaps in a set of changed repo-relative paths: one line per
16 * surface that changed without its counterpart. Empty when consistent.
17 */
18export const contractDrift = (changed: readonly string[]): string[] => {
19 const openapi = changed.includes(OPENAPI)
20 const domain = changed.some(p => GO_DOMAIN.test(p))
21 const store = changed.some(p => GO_STORE.test(p) && !p.endsWith('_test.go'))
22 const web = changed.some(p => WEB_TYPES.test(p))
23 const gaps: string[] = []
24
25 if (openapi && !domain) {
26 gaps.push(`${OPENAPI} changed but iris-api/internal/domain/types.go did not.`)
27 }
28 if (domain && !openapi) {
29 gaps.push(`iris-api/internal/domain/ changed but ${OPENAPI} did not.`)
30 }
31 if ((openapi || domain) && !web) {
32 gaps.push('The API contract changed but nothing in apps/web/src/types/ did.')
33 }
34 if (web && !openapi && !domain) {
35 gaps.push(`apps/web/src/types/ changed but neither ${OPENAPI} nor the Go domain types did.`)
36 }
37 if (domain && !store) {
38 gaps.push(
39 'Go domain types changed but iris-api/internal/store/ did not: if the field is persisted it needs the store and migrations.go (settings in app_settings need no migration).',
40 )
41 }
42
43 return gaps
44}
45
46// --- Pre-push gate --------------------------------------------------------
47
48/** True when a shell command runs `git push` (not merely mentions it). */
49export const isGitPush = (command: string): boolean =>
50 /(^|[;&|(]\s*|\bthen\s+|\bdo\s+)(\S+=\S+\s+)*git(\s+-C\s+\S+)?\s+push\b/m.test(command) &&
51 !/\bgit\s+push\b[^;&|]*--dry-run\b/.test(command)
52
53export type Check = { label: string; cwd: string; argv: string[] }
54
55/** The per-surface checks a push of these changed paths must pass first. */
56export const checksFor = (changed: readonly string[]): Check[] => {
57 const checks: Check[] = []
58
59 if (changed.some(p => p.startsWith('iris-api/'))) {
60 checks.push(
61 { label: 'gofmt -l', cwd: 'iris-api', argv: ['gofmt', '-l', '.'] },
62 { label: 'go vet ./...', cwd: 'iris-api', argv: ['go', 'vet', './...'] },
63 { label: 'go test ./...', cwd: 'iris-api', argv: ['go', 'test', './...'] },
64 )
65 }
66 if (changed.some(p => p.startsWith('apps/web/'))) {
67 checks.push(
68 { label: 'npm run lint', cwd: 'apps/web', argv: ['npm', 'run', 'lint'] },
69 { label: 'npm test', cwd: 'apps/web', argv: ['npm', 'test'] },
70 { label: 'npm run build', cwd: 'apps/web', argv: ['npm', 'run', 'build'] },
71 )
72 }
73
74 return checks
75}
76
77/** The last `lines` lines of a check's output, for the model to read. */
78export const tail = (text: string, lines = 40): string =>
79 text.trimEnd().split('\n').slice(-lines).join('\n')
80
81// --- Production guard -----------------------------------------------------
82
83export type ProdRule = { name: string; command: RegExp; askedFor: RegExp }
84
85/**
86 * Commands that reach the production server or discard work, each paired with
87 * the words that show the person asked for it in their latest prompt.
88 */
89export const PROD_RULES: readonly ProdRule[] = [
90 {
91 name: 'a production deploy or server access (make deploy/ssh/logs/db-pull)',
92 command: /\bmake\s+(\S+\s+)*(deploy|ssh|logs|db-pull)\b/,
93 askedFor: /\b(deploy|roll ?back|release|ssh|server|logs?|db-pull|backup|prod(uction)?)\b/i,
94 },
95 {
96 name: 'an ssh/scp session to a remote host',
97 command: /(^|[;&|(]\s*)(\S+=\S+\s+)*(ssh|scp|rsync)\s+\S/m,
98 askedFor: /\b(ssh|scp|rsync|server|hetzner|prod(uction)?)\b/i,
99 },
100 {
101 name: 'docker compose against docker-compose.prod.yml',
102 command: /\bdocker(-compose|\s+compose)\b[^;&|]*docker-compose\.prod\.yml/,
103 askedFor: /\b(prod(uction)?|deploy|docker)\b/i,
104 },
105 {
106 name: 'a hard reset that discards local work (git reset --hard)',
107 command: /\bgit\s+(\S+\s+)*reset\s+(\S+\s+)*--hard\b/,
108 askedFor: /\b(reset|discard|throw away|start over)\b/i,
109 },
110 {
111 name: 'a force push',
112 command: /\bgit\s+(\S+\s+)*push\b[^;&|]*(\s--force(-with-lease)?\b|\s-f\b)/,
113 askedFor: /\bforce\b/i,
114 },
115 {
116 name: 'a push straight to main (which deploys)',
117 command: /\bgit\s+(\S+\s+)*push\s+(\S+\s+)*(\S+:)?(refs\/heads\/)?main(?=$|\s|[;&|)])/m,
118 askedFor: /\b(push|merge)\b[^.]*\bmain\b|\bmain\b[^.]*\bpush\b/i,
119 },
120 {
121 name: 'a manual deploy workflow run',
122 command: /\bgh\s+workflow\s+run\b[^;&|]*deploy/,
123 askedFor: /\bdeploy\b/i,
124 },
125]
126
127/** The production rules a command trips. */
128export const prodRulesFor = (command: string): ProdRule[] =>
129 PROD_RULES.filter(rule => rule.command.test(command))
130