SLOPSHOPPER

guardrails

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

newguardtoaststatuspromptmodel
v0.1.2MITupdated 2026-10-19Egoushka/claude-mods/mods/guardrails
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guardrails
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ guardrails: guard: 1 masked
README

guardrails

Rules you'd otherwise write in CLAUDE.md and hope the model follows, enforced by the engine.

FeatureWhat it doesHooks
BoundaryA 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 maskingKeys, 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 opinionBefore 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 autocorrectcd 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 breakerThe 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 budgetAfter 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.

Settings

FieldDefaultMeaning
boundaryPathsempty (off)Regex over the session folder that marks a fenced session, e.g. `/work(/\$)`
fencedToolsemptyRegex over tool names blocked inside, e.g. `^mcp__(personal-notes\home)__`
fencedHostsemptyRegex over URLs/commands blocked inside, e.g. my-home\.example
insideOnlyToolsemptyRegex over tools callable only inside
maskSecretstrue
secondOpiniontrue
shellFixestrue
loopBreakertrue
diffBudget0Lines; 0 sizes it per task with Haiku, -1 turns it off

Cost

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.

Limits

Regex rules are best effort: a command built to dodge them will. This is a seatbelt against mistakes, not a sandbox.

Source 3 files
hooks/register.tsx 219 lines
1import { 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}
219
hooks/rules.ts 128 lines
1// 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}
128
types/index.d.ts 16 lines
1export 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