Boundaries, secret masking, a second opinion before irreversible commands, shell autocorrect, loop breaker, diff budget

Rules you'd otherwise write in CLAUDE.md and hope the model follows, enforced by the engine.
| Feature | What it does | Hooks | |
|---|---|---|---|
| Boundary | A session whose folder matches boundaryPaths cannot call fencedTools or reach fencedHosts (WebFetch URLs, shell commands). Sessions outside it cannot call insideOnlyTools. Use it to keep employer code away from personal services, or the reverse. | session.start, tool.call | |
| Secret masking | Keys, tokens, private keys, JWTs, connection-string passwords and *_TOKEN=-style assignments in tool output become [masked:<kind>] before the model reads them. Placeholders like ${TOKEN} are left alone. | session.append (door tool-result) | |
| Second opinion | Before a force push, hard reset, git clean -f, branch delete, rm -rf, SQL drop/truncate/unbounded delete, EF migration, container wipe or repo delete, a forked model call reads the conversation and answers ALLOW or DENY. DENY stops the command and tells Claude to get explicit confirmation. Each approved command is asked once per session. | tool.call, $.model.fork | |
| Shell autocorrect | cd dir && gh pr … becomes gh pr … -R owner/repo; `gh … \ | jq is stopped with the --json --jq alternative; timeout N is dropped (macOS has none); echo ===` gets quoted for zsh. Claude is told what changed. | tool.call |
| Loop breaker | The fourth identical failing shell command is stopped, and so is the third Edit that flips a file back to a previous version. | tool.call | |
| Diff budget | After each edit, lines changed against HEAD show in the status line. Over budget, one toast asks for a smaller change. The budget is sized per task by Haiku unless you fix one. | prompt.submit, tool.call |
The status line reads like guard: 2 masked · 1 corrected · diff +120 −30 of 150.
| Field | Default | Meaning | |
|---|---|---|---|
boundaryPaths | empty (off) | Regex over the session folder that marks a fenced session, e.g. `/work(/\ | $)` |
fencedTools | empty | Regex over tool names blocked inside, e.g. `^mcp__(personal-notes\ | home)__` |
fencedHosts | empty | Regex over URLs/commands blocked inside, e.g. my-home\.example | |
insideOnlyTools | empty | Regex over tools callable only inside | |
maskSecrets | true | ||
secondOpinion | true | ||
shellFixes | true | ||
loopBreaker | true | ||
diffBudget | 0 | Lines; 0 sizes it per task with Haiku, -1 turns it off |
The second opinion is one forked request per distinct irreversible command; it reuses the session's prompt cache, so you pay mostly for the cached prefix read. The diff budget is one Haiku classification per typed prompt (set diffBudget to a number or -1 to skip it). Everything else is local.
Regex rules are best effort: a command built to dodge them will. This is a seatbelt against mistakes, not a sandbox.
hooks/register.tsx 219 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GuardStats } from '../types'
5import {
6 boundaryDenial,
7 budgetFor,
8 compile,
9 irreversibleKind,
10 maskSecrets,
11 repoFromRemote,
12 shellCorrection,
13} from './rules'
14import type { Boundary } from './rules'
15
16const initial: GuardStats = { isInside: false, masked: 0, corrected: 0, denied: 0, added: 0, removed: 0, budget: null }
17const stats = atom({ plugin: 'guardrails', key: 'stats' } as const, initial)
18
19const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
20const normalize = (command: string) => command.trim().replace(/\s+/g, ' ')
21
22function bump($: EngineInterface, patch: (s: GuardStats) => Partial<GuardStats>) {
23 return update($, stats, s => ({ ...s, ...patch(s) }))
24}
25
26async function refreshStatus($: EngineInterface) {
27 const s = await read($, stats)
28 const parts: string[] = []
29 if (s.isInside) parts.push('inside boundary')
30 if (s.masked) parts.push(`${s.masked} masked`)
31 if (s.corrected) parts.push(`${s.corrected} corrected`)
32 if (s.denied) parts.push(`${s.denied} stopped`)
33 if (s.added || s.removed) parts.push(`diff +${s.added} −${s.removed}${s.budget ? ` of ${s.budget}` : ''}`)
34 $.ui.status(parts.length ? `guard: ${parts.join(' · ')}` : undefined)
35}
36
37async function deny($: EngineInterface, reason: string) {
38 await bump($, s => ({ denied: s.denied + 1 }))
39 await refreshStatus($)
40 return { deny: `guardrails: ${reason}` }
41}
42
43// Lines changed against HEAD; the toast fires once per budget.
44async function measureDiff($: EngineInterface, cwd: string, isToastShown: boolean): Promise<boolean> {
45 const r = await $.process.run(['git', '-C', cwd, 'diff', 'HEAD', '--numstat']).catch(() => undefined)
46 if (!r || r.exitCode !== 0) return isToastShown
47 let added = 0
48 let removed = 0
49 for (const line of r.stdout.split('\n')) {
50 const [a, d] = line.split('\t')
51 added += Number(a) || 0
52 removed += Number(d) || 0
53 }
54 const s = await bump($, () => ({ added, removed }))
55 await refreshStatus($)
56 if (!s.budget || added + removed <= s.budget || isToastShown) return isToastShown
57 $.ui.toast(`guardrails: the diff is ${added + removed} lines, past this task's budget of ${s.budget}. Smallest change?`)
58 return true
59}
60
61export const register: Register = (on, options) => {
62 const boundary: Boundary = {
63 paths: compile(options.boundaryPaths),
64 fencedTools: compile(options.fencedTools),
65 fencedHosts: compile(options.fencedHosts),
66 insideOnlyTools: compile(options.insideOnlyTools),
67 }
68 const isMasking = options.maskSecrets !== false
69 const isSecondOpinionOn = options.secondOpinion !== false
70 const isFixingShell = options.shellFixes !== false
71 const isBreakingLoops = options.loopBreaker !== false
72 const budgetOption = typeof options.diffBudget === 'number' ? options.diffBudget : 0
73 const fixedBudget = budgetOption > 0 ? budgetOption : null
74
75 // Per-session bookkeeping; a reload starts it over, which is fine for counters this short-lived.
76 let cwd = ''
77 let isInside = false
78 let isOverBudgetToastShown = false
79 const failures = new Map<string, number>()
80 const edits = new Map<string, { old: string; new: string; flips: number }>()
81 const approved = new Set<string>()
82 // The person's newest messages: a fork reads the transcript as last sent, which can miss one sent mid-turn.
83 let latest: string[] = []
84
85 on('session.start', async ($, e, next) => {
86 cwd = e.cwd
87 isInside = boundary.paths?.test(cwd) ?? false
88 await update($, stats, () => ({ ...initial, isInside, budget: fixedBudget }))
89 await refreshStatus($)
90 return next(e)
91 })
92
93 // Secrets in tool output are masked before the model reads them.
94 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
95 if (!isMasking) return next(e)
96 let count = 0
97 const mask = (text: string) => {
98 const m = maskSecrets(text)
99 count += m.count
100 return m.text
101 }
102 const content = e.message.content.map(block => {
103 if (block.type !== 'tool_result') return block
104 if (typeof block.content === 'string') return { ...block, content: mask(block.content) }
105 if (!Array.isArray(block.content)) return block
106 return {
107 ...block,
108 content: block.content.map((inner: { type?: unknown; text?: unknown }) =>
109 inner?.type === 'text' && typeof inner.text === 'string' ? { ...inner, text: mask(inner.text) } : inner,
110 ),
111 }
112 })
113 if (count === 0) return next(e)
114 await bump($, s => ({ masked: s.masked + count }))
115 await refreshStatus($)
116 return next({ ...e, message: { ...e.message, content } })
117 })
118
119 // The diff budget is sized per task by a quick classification unless one is fixed.
120 on('prompt.submit', async ($, e, next) => {
121 if (!e.isMeta && e.text.trim()) latest = [...latest, e.text.trim().slice(0, 500)].slice(-3)
122 if (budgetOption === 0 && !e.command && !e.isMeta && e.text.trim().length > 15) {
123 void $.model
124 .classify(e.text.slice(0, 2000), ['tiny', 'small', 'medium', 'large'], { model: 'haiku' })
125 .then(async size => {
126 isOverBudgetToastShown = false
127 await bump($, () => ({ budget: budgetFor(size) }))
128 await refreshStatus($)
129 })
130 .catch(() => undefined)
131 }
132 return next(e)
133 })
134
135 on('tool.call', async ($, e, next) => {
136 const tool = String(e.tool)
137
138 const crossing = boundaryDenial(boundary, isInside, tool, e as unknown as Record<string, unknown>)
139 if (crossing) return deny($, crossing)
140
141 let rewritten: string | undefined
142 const notes: string[] = []
143
144 if (e.tool === 'Bash') {
145 const key = normalize(e.command)
146
147 if (isBreakingLoops && (failures.get(key) ?? 0) >= 3)
148 return deny($, 'this exact command failed 3 times in a row. Stop retrying it: read the error, change the approach, or ask the person.')
149
150 if (isFixingShell) {
151 const c = shellCorrection(e.command)
152 if (c.deny) return deny($, c.deny)
153 if (c.cdGh) {
154 const remote = await $.process.run(['git', '-C', c.cdGh.dir, 'remote', 'get-url', 'origin']).catch(() => undefined)
155 const repo = remote?.exitCode === 0 ? repoFromRemote(remote.stdout) : undefined
156 if (!repo) return deny($, 'gh works here only as a standalone call: drop the `cd … &&` and pass `-R owner/repo`.')
157 rewritten = `${c.cdGh.gh} -R ${repo}`
158 notes.push(`guardrails rewrote \`cd … && gh\` to \`${rewritten}\` (gh works only as a standalone call here).`)
159 } else if (c.fix) {
160 rewritten = c.fix.command
161 notes.push(`guardrails rewrote the command to \`${rewritten}\`: ${c.fix.why}`)
162 }
163 if (rewritten) await bump($, s => ({ corrected: s.corrected + 1 }))
164 }
165
166 const command = rewritten ?? e.command
167 const kind = irreversibleKind(command)
168 if (kind && isSecondOpinionOn && !approved.has(normalize(command))) {
169 $.ui.status(`guard: second opinion on ${kind}…`)
170 const reply = await $.model.fork({
171 prompt:
172 `A guard paused this shell command (${kind}) before it runs:\n\n${command}\n\n` +
173 (latest.length ? `The person's newest messages, which may be newer than the transcript you see:\n${latest.map(t => `- ${t}`).join('\n')}\n\n` : '') +
174 'Judge only from this conversation and those messages, counting standing instructions (CLAUDE.md, project rules) and tool output already in it as evidence. ' +
175 'ALLOW when the person asked for it, a standing rule calls for it, or the output shows it is the expected next step ' +
176 '(say, deleting a branch whose merge the output shows). DENY when it could destroy work not shown to be saved elsewhere, ' +
177 'or when it aims at the wrong target. Reply with exactly one line: "ALLOW: <reason>" or "DENY: <reason>".',
178 })
179 await refreshStatus($)
180 if (reply.isAnswered) {
181 const line = reply.text.trim().split('\n')[0] ?? ''
182 if (/^\W*DENY/i.test(line))
183 return deny(
184 $,
185 `second opinion on ${kind}: ${line.replace(/^\W*DENY:?\s*/i, '')} Ask the person to confirm this exact command, then retry.`,
186 )
187 approved.add(normalize(command))
188 } else {
189 $.ui.toast(`guardrails: no second opinion (${reply.reason}); the ${kind} went ahead`)
190 }
191 }
192 }
193
194 if (isBreakingLoops && e.tool === 'Edit') {
195 const prev = edits.get(e.file_path)
196 const flips = prev && prev.old === e.new_string && prev.new === e.old_string ? prev.flips + 1 : 0
197 if (flips >= 2)
198 return deny($, `this Edit flips ${e.file_path} back again (${flips + 1} times). Decide which version is right before editing.`)
199 edits.set(e.file_path, { old: e.old_string, new: e.new_string, flips })
200 }
201
202 const ran = await next(e.tool === 'Bash' && rewritten ? { ...e, command: rewritten } : e)
203 if (ran.deny !== undefined) return ran
204
205 if (e.tool === 'Bash') {
206 const key = normalize(e.command)
207 if (ran.isError) failures.set(key, (failures.get(key) ?? 0) + 1)
208 else failures.delete(key)
209 }
210
211 if (budgetOption >= 0 && EDIT_TOOLS.has(tool) && !ran.isError)
212 void measureDiff($, cwd, isOverBudgetToastShown).then(shown => (isOverBudgetToastShown = shown))
213
214 if (notes.length === 0) return ran
215 await refreshStatus($)
216 return { ...ran, context: [...(ran.context ?? []), ...notes] }
217 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'guardrails: its guard failed; retry once.' }))
218}
219hooks/rules.ts 128 lines1// Pure rules, kept apart from the hooks so tests can hit them directly.
2
3export type Boundary = {
4 paths?: RegExp
5 fencedTools?: RegExp
6 fencedHosts?: RegExp
7 insideOnlyTools?: RegExp
8}
9
10export function compile(source: unknown): RegExp | undefined {
11 if (typeof source !== 'string' || source.trim() === '') return undefined
12 try {
13 return new RegExp(source)
14 } catch {
15 return undefined
16 }
17}
18
19// What a call would cross: a fenced session reaching out, or an outside session writing in.
20export function boundaryDenial(b: Boundary, isInside: boolean, tool: string, input: Record<string, unknown>): string | undefined {
21 if (isInside) {
22 if (b.fencedTools?.test(tool)) return `${tool} is outside this session's boundary.`
23 const url = typeof input.url === 'string' ? input.url : ''
24 if (tool === 'WebFetch' && b.fencedHosts?.test(url)) return `${url} is outside this session's boundary.`
25 const command = typeof input.command === 'string' ? input.command : ''
26 if (tool === 'Bash' && b.fencedHosts?.test(command)) return 'this command reaches a host outside this session\'s boundary.'
27 return undefined
28 }
29 if (b.insideOnlyTools?.test(tool)) return `${tool} may be called only from inside the boundary.`
30 return undefined
31}
32
33const SECRETS: [string, RegExp][] = [
34 ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
35 ['anthropic', /\bsk-ant-[A-Za-z0-9_-]{20,}/g],
36 ['openai', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g],
37 ['github', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})/g],
38 ['gitlab', /\bglpat-[A-Za-z0-9_-]{20,}/g],
39 ['aws', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
40 ['slack', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g],
41 ['google', /\bAIza[0-9A-Za-z_-]{35}\b/g],
42 ['stripe', /\b[rs]k_live_[A-Za-z0-9]{20,}/g],
43 ['jwt', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
44 ['telegram-bot', /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/g],
45 // KEY=value assignments of env-style names (upper case, so code like `const tokens = x` is left alone); keeps the name
46 ['assignment', /\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY)[A-Z0-9_]*)\s*[=:]\s*["']?([^\s"'`]{8,})/g],
47 ['conn-string', /\b(Password|Pwd)=([^;"'\s]{4,})/gi],
48]
49
50// A dotted path or a call (process.env.TOKEN, getToken()) is code that reads a secret, not the secret.
51const isCodeLike = (value: string) => /^[A-Za-z_$][\w$]*(\.[\w$]+)+[;,)]*$|\(/.test(value)
52
53export function maskSecrets(text: string): { text: string; count: number } {
54 let count = 0
55 let out = text
56 for (const [kind, re] of SECRETS) {
57 out = out.replace(re, (match, name?: string, value?: string) => {
58 if (match.includes('[masked:')) return match
59 count += 1
60 if ((kind === 'assignment' || kind === 'conn-string') && name && value) {
61 if (/^\$\{?[A-Z_]+\}?$|^<.*>$|^\*+$|^(true|false|null|undefined)$/i.test(value) || isCodeLike(value)) {
62 count -= 1
63 return match
64 }
65 return match.replace(value, `[masked:${kind}]`)
66 }
67 return `[masked:${kind}]`
68 })
69 }
70 return { text: out, count }
71}
72
73const IRREVERSIBLE: [string, RegExp][] = [
74 ['force push', /\bgit\s+push\b[^;&|]*(\s--force\b|\s-f\b|\s\+\S+)/],
75 ['hard reset', /\bgit\s+reset\s+--hard\b/],
76 ['git clean', /\bgit\s+clean\s+-[a-z]*f/],
77 ['branch delete', /\bgit\s+branch\s+-D\b/],
78 ['recursive delete', /\brm\s+-[a-z]*r[a-z]*f?\b(?![^;&|]*(\$TMPDIR|\/tmp\/claude|node_modules|\bbin\/|\bobj\/|dist\/|\.cache))/],
79 ['sql drop', /\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b/i],
80 ['sql delete without where', /\bDELETE\s+FROM\s+\S+\s*(;|$|")/i],
81 ['migration', /\bdotnet\s+ef\s+database\s+update\b/],
82 ['container wipe', /\b(docker\s+(system|volume)\s+prune|docker\s+rm\s+-f|kubectl\s+delete|terraform\s+destroy)\b/],
83 ['repo delete', /\bgh\s+repo\s+delete\b/],
84]
85
86export function irreversibleKind(command: string): string | undefined {
87 return IRREVERSIBLE.find(([, re]) => re.test(command))?.[0]
88}
89
90export type Correction = { command: string; why: string }
91
92// gh subcommands that accept -R owner/repo
93const GH_REPO_SCOPED = /^gh\s+(pr|issue|run|workflow|release|label|cache|secret|variable|repo\s+view)\b/
94
95export function shellCorrection(command: string): { deny?: string; cdGh?: { dir: string; gh: string }; fix?: Correction } {
96 const trimmed = command.trim()
97 const cdGh = /^cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*(gh\s.+)$/s.exec(trimmed)
98 if (cdGh) {
99 const dir = cdGh[1]!.replace(/^["']|["']$/g, '')
100 const gh = cdGh[2]!.trim()
101 if (/[|>]/.test(gh)) return { deny: ghPipeAdvice }
102 if (GH_REPO_SCOPED.test(gh) && !/\s(-R|--repo)\s/.test(gh)) return { cdGh: { dir, gh } }
103 return { deny: 'gh leaves the sandbox only as a standalone call: drop the `cd … &&` and pass `-R owner/repo`.' }
104 }
105 if (/^gh\s/.test(trimmed) && /\|\s*(jq|head|grep)|\s>\s?\S/.test(trimmed)) return { deny: ghPipeAdvice }
106
107 const timeout = /^timeout\s+\d+[smh]?\s+(.+)$/s.exec(trimmed)
108 if (timeout) return { fix: { command: timeout[1]!, why: 'macOS has no `timeout`; dropped it (use the Bash tool timeout).' } }
109
110 if (/^(echo|printf)\s/.test(trimmed) && /(^|\s)=[^\s'"]*/.test(trimmed)) {
111 const quoted = trimmed.replace(/(^|\s)(=[^\s'"]*)/g, (_, pre: string, word: string) => `${pre}'${word}'`)
112 if (quoted !== trimmed) return { fix: { command: quoted, why: "quoted words starting with `=` (zsh expands them)." } }
113 }
114 return {}
115}
116
117const ghPipeAdvice =
118 'gh leaves the sandbox only as a standalone call: no pipes or redirects. Use `--json <fields> --jq <expr>` instead of `| jq`.'
119
120export function repoFromRemote(url: string): string | undefined {
121 const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(\.git)?\s*$/.exec(url.trim())
122 return m ? `${m[1]}/${m[2]}` : undefined
123}
124
125export function budgetFor(size: string | undefined): number {
126 return { tiny: 40, small: 150, medium: 500, large: 1500 }[size ?? ''] ?? 500
127}
128types/index.d.ts 16 lines1export type GuardStats = {
2 isInside: boolean
3 masked: number
4 corrected: number
5 denied: number
6 added: number
7 removed: number
8 budget: number | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 guardrails: { stats: GuardStats }
14 }
15}
16