SLOPSHOPPER

guardrails

Stops pushes, PRs and raw commits you did not ask for, steers Bash to the right tools, and runs each project's own formatter on the file Claude just edited.

newguardpromptprocess
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(rm -rf build && git push --force origin main) ⎿ Denied by guardrails: guardrails: The user did not ask for a push in their last message. Ask them first. ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

guardrails

Keep Claude's hands off the things you want to decide, steer its shell commands to the tools you use, and tidy each file it edits with the project's own formatter.

guardrails is a Claude Code mod. It works in any project: every rule switches on from what the project contains (a lockfile, vendor/bin/pint, go.mod, a CLAUDE.md line), never from its name.

Requirements

  • A Claude Code build that loads plugin modules (mods).

Git

CommandRuns when
git pushYour last message asked for it: it says push or ship, and not after a negation such as "don't push".
gh pr create, gh pr readyYour last message says PR, pull request or ship.
gh pr mergeYour last message says merge or ship.
git commitA commit skill (any skill named commit, such as mella:commit) is running: Claude called it, or you typed /mella:commit. The window closes when the turn ends. With no commit skill installed, commits are left alone.
git merge --no-ffNever, where the project's CLAUDE.md or AGENTS.md mentions --no-ff.
A commit or PR whose text holds a Claude session linkNever.

"Your last message" is the last prompt you typed, at the terminal, through Remote Control or as a claude -p prompt. Background task notifications and messages from other sessions or plugins do not count. The next prompt you type closes the door again.

Shell

Refused, with a hint that names the right tool:

  • CI polling: gh run watch, gh pr checks --watch, and while/until loops around sleep. Use the Monitor tool.
  • sed -i (use sd or the Edit tool), grep (use rg), find (use fd).
  • gh run view --log-failed that is not written to a file.
  • File edits from Bash: perl -pi and ruby -i, cat or tee heredocs into a file, and Python, Node, Ruby, Perl or PHP scripts, inline or saved to a file, that write to a path inside the project. Scripts that only read, or write outside the project (such as /tmp), still run.

Project tools

After each Write or Edit, the formatter the project has set up runs on that one file. Claude is told what it changed, so it re-reads the file before its next edit.

FileFormatterWhen
*.phpPint (vendor/bin/pint --format agent)vendor/bin/pint exists. Blade files only with Pint/laravel_blade. notPath in pint.json is respected.
*.blade.phpsheath (artisan sheath:lint --fix)config/sheath.php exists. Runs before Pint, so Pint formats last. Only safe fixes are applied; the rest is passed to Claude.
JS, TS, CSS, JSON, Markdownvp fmtvite.config.* has a fmt block and node_modules/.bin/vp exists. ignorePatterns is respected.
JS, TS, CSS, JSONBiomebiome.json(c) and node_modules/.bin/biome exist, and there is no vp fmt.
JS, TS, CSS, Blade, MarkdownPrettierA Prettier config and node_modules/.bin/prettier exist, and there is no vp fmt or Biome.
*.gogofmtgo.mod exists and gofmt is on the PATH.
*.rsrustfmt, with the edition from Cargo.tomlCargo.toml exists and rustfmt is on the PATH.
*.pyRuff, or BlackRuff or Black is configured, and installed in .venv or on the PATH.
*.swiftswift-format.swift-format exists and swift-format is on the PATH.

Files in vendor, node_modules, target, .venv, build and dist are never formatted. Linters, static analysis and tests never run per edit: they are too slow and work on the whole project.

Commands:

  • The package manager comes from packageManager in package.json, then the lockfile. The others are refused (npm in a bun project, bunx in a pnpm project).
  • pint --test is refused where Pint is installed. Run pint --dirty --format agent to fix instead.
  • --coverage together with --tia is refused.
  • In a Laravel project, a single artisan command gets --no-interaction.
  • Where /opt/homebrew/bin/valet exists, bare valet is refused, since it resolves to the Composer copy and asks for a password.

Options

Each part has its own switch in /config, all on by default.

OptionWhat it covers
Git guardThe Git table.
Shell guardThe Shell list.
Project toolsFormatters and the project command rules.

Limits

  • The guard reads commands as text. A command it cannot see into, such as a script that runs git push itself, is not caught.
  • A script that creates a brand-new file at the project root by bare name is not caught. One that writes into a project folder is.
  • If the guard itself fails on a command, the command is refused rather than run.

Development

The rules are pure functions in hooks/git.ts, hooks/shell.ts and hooks/project.ts. The hooks, project detection and fixer runs are in hooks/register.ts.

claude plugin validate plugins/guardrails
claude plugin test plugins/guardrails

License

MIT

Source 5 files
hooks/register.ts 278 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import { gitDenial } from './git'
5import { cargoEdition, fixersFor, hasFmtBlock, packageManagerFrom, pintBlade, projectDenial, pythonFormatter, stringArray, withNoInteraction } from './project'
6import type { Fixer, Project } from './project'
7import { SCRIPT_EDIT_HINT, inProject, inlineScript, scriptFiles, shellDenial, writtenPaths } from './shell'
8
9const PLUGIN = 'guardrails'
10const FIXER_MS = 60_000
11const REPORT_CHARS = 1_500
12const SCRIPT_READ_LIMIT = 200_000
13const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
14const COMMIT_SKILL = /(?:^|:)commit$/
15const COMMIT_COMMAND = /^\s*\/(?:[\w-]+:)?commit(?:\s|$)/
16
17const lastPrompt = atom({ plugin: 'guardrails', key: 'lastPrompt' } as const, '')
18const committing = atom({ plugin: 'guardrails', key: 'committing' } as const, false)
19
20type Outcome = { exitCode: number; stdout: string; stderr: string }
21
22async function run($: EngineInterface, argv: string[], cwd?: string, timeoutMs?: number): Promise<Outcome> {
23  try {
24    return await $.process.run(argv, { ...(cwd ? { cwd } : {}), ...(timeoutMs ? { timeoutMs } : {}) })
25  } catch (error) {
26    return { exitCode: 127, stdout: '', stderr: String(error) }
27  }
28}
29
30const dirname = (path: string) => path.replace(/\/[^/]*$/, '') || '/'
31
32const readText = async ($: EngineInterface, path: string) => ((await $.fs.exists(path)) ? String(await $.fs.read(path)) : '')
33
34async function firstExisting($: EngineInterface, paths: string[]): Promise<string | undefined> {
35  for (const path of paths) {
36    if (await $.fs.exists(path)) {
37      return path
38    }
39  }
40
41  return undefined
42}
43
44async function rootOf($: EngineInterface, dir: string): Promise<string> {
45  const outcome = await run($, ['git', '-C', dir, 'rev-parse', '--show-toplevel'])
46
47  return outcome.exitCode === 0 && outcome.stdout.trim() !== '' ? outcome.stdout.trim() : dir
48}
49
50const onPath = new Map<string, string | null>()
51
52async function which($: EngineInterface, name: string): Promise<string | null> {
53  if (!onPath.has(name)) {
54    const outcome = await run($, ['sh', '-c', `command -v ${name}`])
55
56    onPath.set(name, outcome.exitCode === 0 && outcome.stdout.trim() !== '' ? outcome.stdout.trim() : null)
57  }
58
59  return onPath.get(name) ?? null
60}
61
62const PRETTIER_CONFIGS = [
63  '.prettierrc',
64  '.prettierrc.json',
65  '.prettierrc.yaml',
66  '.prettierrc.yml',
67  '.prettierrc.js',
68  '.prettierrc.cjs',
69  '.prettierrc.mjs',
70  'prettier.config.js',
71  'prettier.config.cjs',
72  'prettier.config.mjs',
73]
74
75async function inspect($: EngineInterface, dir: string): Promise<Project> {
76  const root = await rootOf($, dir)
77  const at = (rel: string) => `${root}/${rel}`
78  const top = new Set((await $.fs.list(root).catch(() => [])).map(entry => entry.name))
79  const text = (rel: string) => (top.has(rel) ? readText($, at(rel)) : Promise.resolve(''))
80  const instructions = `${await text('CLAUDE.md')}\n${await text('AGENTS.md')}`
81  const packageJson = await text('package.json')
82  const pintJson = await text('pint.json')
83  const viteFile = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'].find(name => top.has(name))
84  const viteConfig = viteFile ? await text(viteFile) : ''
85  const local = async (bin: string) => ((await $.fs.exists(at(`node_modules/.bin/${bin}`))) ? at(`node_modules/.bin/${bin}`) : null)
86  const pythonTool = pythonFormatter(await text('pyproject.toml'), top.has('ruff.toml') || top.has('.ruff.toml'))
87  const pythonBin = pythonTool ? ((await firstExisting($, [at(`.venv/bin/${pythonTool}`), at(`venv/bin/${pythonTool}`)])) ?? (await which($, pythonTool))) : null
88  const vpBin = viteFile && hasFmtBlock(viteConfig) ? await local('vp') : null
89  const biomeBin = top.has('biome.json') || top.has('biome.jsonc') ? await local('biome') : null
90  const prettierBin = PRETTIER_CONFIGS.some(name => top.has(name)) || /"prettier"\s*:\s*\{/.test(packageJson) ? await local('prettier') : null
91  const gofmtBin = top.has('go.mod') ? await which($, 'gofmt') : null
92  const rustfmtBin = top.has('Cargo.toml') ? await which($, 'rustfmt') : null
93  const swiftFormatBin = top.has('.swift-format') ? await which($, 'swift-format') : null
94
95  return {
96    root,
97    laravel: top.has('artisan'),
98    packageManager: packageManagerFrom(top, packageJson),
99    homebrewValet: await $.fs.exists('/opt/homebrew/bin/valet'),
100    forbidsNoFf: /--no-ff\b/.test(instructions),
101    pint: (await $.fs.exists(at('vendor/bin/pint'))) ? { notPaths: stringArray(pintJson, 'notPath'), blade: pintBlade(pintJson) } : null,
102    sheath: top.has('artisan') && (await $.fs.exists(at('config/sheath.php'))),
103    vp: vpBin ? { bin: vpBin, ignore: stringArray(viteConfig, 'ignorePatterns') } : null,
104    biome: biomeBin ? { bin: biomeBin } : null,
105    prettier: prettierBin ? { bin: prettierBin } : null,
106    gofmt: gofmtBin ? { bin: gofmtBin } : null,
107    rustfmt: rustfmtBin ? { bin: rustfmtBin, edition: cargoEdition(await text('Cargo.toml')) } : null,
108    python: pythonTool && pythonBin ? { bin: pythonBin, tool: pythonTool } : null,
109    swiftFormat: swiftFormatBin ? { bin: swiftFormatBin } : null,
110  }
111}
112
113let commitSkill: string | null | undefined
114
115async function findCommitSkill($: EngineInterface): Promise<string | null> {
116  if (commitSkill === undefined) {
117    const commands = await $.command.list().catch(() => [])
118
119    commitSkill = commands.map(command => command.name).find(name => COMMIT_SKILL.test(name)) ?? null
120  }
121
122  return commitSkill
123}
124
125const deniedOrFailed = (result: unknown): boolean => {
126  const shape = result as { deny?: unknown; isError?: unknown } | undefined
127
128  return shape?.deny !== undefined || shape?.isError === true
129}
130
131const clip = (text: string) => (text.length > REPORT_CHARS ? `${text.slice(0, REPORT_CHARS)}…` : text)
132
133async function applyFixer($: EngineInterface, fixer: Fixer, root: string): Promise<string | undefined> {
134  const outcome = await run($, fixer.argv, root, FIXER_MS)
135  const output = `${outcome.stdout}\n${outcome.stderr}`.trim()
136
137  if (outcome.exitCode === 0 && output === '') {
138    return undefined
139  }
140
141  return `${PLUGIN}: ${fixer.name} ran on the file you just changed (exit ${outcome.exitCode}). Re-read the file before you edit it again.\n${clip(output)}`
142}
143
144async function scriptDenial($: EngineInterface, command: string, cwd: string, root: string): Promise<string | undefined> {
145  const sources: [string, string][] = []
146  const inline = inlineScript(command)
147
148  if (inline) {
149    sources.push(['This script', inline])
150  }
151
152  for (const path of scriptFiles(command)) {
153    const source = await readText($, path.startsWith('/') ? path : `${cwd}/${path}`)
154
155    if (source.length <= SCRIPT_READ_LIMIT) {
156      sources.push([path, source])
157    }
158  }
159
160  const candidates = sources.map(([label, source]) => [label, writtenPaths(source)] as const).filter(([, paths]) => paths.length > 0)
161
162  if (candidates.length === 0) {
163    return undefined
164  }
165
166  const topLevel = new Set((await $.fs.list(cwd).catch(() => [])).map(entry => entry.name))
167
168  for (const [label, paths] of candidates) {
169    const target = paths.find(path => inProject(path, root, topLevel))
170
171    if (target) {
172      return `${label} writes ${target}. ${SCRIPT_EDIT_HINT}`
173    }
174  }
175
176  return undefined
177}
178
179async function fix<T>($: EngineInterface, file: string, result: T): Promise<T> {
180  if (deniedOrFailed(result)) {
181    return result
182  }
183
184  const project = await inspect($, dirname(file))
185  const reports: string[] = []
186
187  for (const fixer of fixersFor(file, project)) {
188    const report = await applyFixer($, fixer, project.root)
189
190    if (report) {
191      reports.push(report)
192    }
193  }
194
195  if (reports.length === 0) {
196    return result
197  }
198
199  return { ...result, context: [...((result as { context?: readonly string[] }).context ?? []), ...reports] }
200}
201
202export const register: Register = (on, options) => {
203  const enabled = {
204    git: options.git !== false,
205    shell: options.shell !== false,
206    project: options.project !== false,
207  }
208
209  on('prompt.submit', async ($, e, next) => {
210    if (HUMAN_ORIGINS.has(e.origin.kind)) {
211      await update($, lastPrompt, () => e.text)
212
213      if (COMMIT_COMMAND.test(e.text)) {
214        await update($, committing, () => true)
215      }
216    }
217
218    return next(e)
219  })
220
221  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
222    if (e.tool === 'Skill' && COMMIT_SKILL.test(e.skill)) {
223      await update($, committing, () => true)
224    }
225
226    return next(e)
227  })
228
229  on('turn.complete', async ($, e, next) => {
230    if (e.agentId === undefined) {
231      await update($, committing, () => false)
232    }
233
234    return next(e)
235  })
236
237  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
238    if (e.tool !== 'Bash') {
239      return next(e)
240    }
241
242    const cwd = await $.session.cwd()
243    const project = enabled.project || enabled.git ? await inspect($, cwd) : null
244
245    const denial =
246      (enabled.git && project
247        ? gitDenial(e.command, {
248            lastPrompt: await read($, lastPrompt),
249            committing: await read($, committing),
250            commitSkill: await findCommitSkill($),
251            forbidsNoFf: project.forbidsNoFf,
252          })
253        : undefined) ??
254      (enabled.shell ? (shellDenial(e.command) ?? (await scriptDenial($, e.command, cwd, project?.root ?? (await rootOf($, cwd))))) : undefined) ??
255      (enabled.project && project ? projectDenial(e.command, project) : undefined)
256
257    if (denial) {
258      return { deny: `${PLUGIN}: ${denial}` }
259    }
260
261    const rewritten = enabled.project && project ? withNoInteraction(e.command, project) : undefined
262
263    return next(rewritten ? { ...e, command: rewritten } : e)
264  }).catch(($, e, next) => (next.called ? next(e) : { deny: `${PLUGIN}: the guard failed on this command, so it did not run. Ask the user.` }))
265
266  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
267    const result = await next(e)
268
269    return enabled.project && e.tool === 'Edit' ? fix($, e.file_path, result) : result
270  })
271
272  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
273    const result = await next(e)
274
275    return enabled.project && e.tool === 'Write' ? fix($, e.file_path, result) : result
276  })
277}
278
hooks/git.ts 60 lines
1export type GitContext = {
2  lastPrompt: string
3  committing: boolean
4  commitSkill: string | null
5  forbidsNoFf: boolean
6}
7
8const STARTS = String.raw`(?:^|[|;&(\n]\s*)`
9const GIT = String.raw`${STARTS}git(?:\s+-[Cc]\s+\S+|\s+--?[\w-]+(?:=\S+)?)*\s+`
10const GH = String.raw`${STARTS}gh\s+pr\s+`
11
12const PUSH = new RegExp(`${GIT}push\\b`)
13const COMMIT = new RegExp(`${GIT}commit\\b`)
14const NO_FF_MERGE = new RegExp(`${GIT}merge\\b[^|;&\\n]*--no-ff\\b`)
15const PR_OPEN = new RegExp(`${GH}(?:create|ready)\\b`)
16const PR_MERGE = new RegExp(`${GH}merge\\b`)
17const PUBLISHES = /\bgit\b[^|;&\n]*\bcommit\b|\bgh\s+(?:pr|issue)\b/
18const SESSION_LINK = /claude\.ai\/code\/session_|Claude-Session:/
19
20const PUSH_WORDS = String.raw`push(?:es|ed|ing)?|ship(?:s|ped|ping)?`
21const PR_WORDS = String.raw`prs?|pull[- ]requests?|ship(?:s|ped|ping)?`
22const MERGE_WORDS = String.raw`merg(?:e|es|ed|ing)|ship(?:s|ped|ping)?`
23
24const NEGATION = String.raw`\b(?:don'?t|do\s+not|never|no|not|stop|without|avoid|hold\s+off(?:\s+on)?)\b(?:\s+[\w'-]+){0,3}\s+`
25
26export function asked(prompt: string, words: string): boolean {
27  const mentions = prompt.match(new RegExp(`\\b(?:${words})\\b`, 'gi')) ?? []
28  const negated = prompt.match(new RegExp(`${NEGATION}(?:${words})\\b`, 'gi')) ?? []
29
30  return mentions.length > negated.length
31}
32
33export function gitDenial(command: string, context: GitContext): string | undefined {
34  if (SESSION_LINK.test(command) && PUBLISHES.test(command)) {
35    return 'Remove the Claude session link from the commit or PR text.'
36  }
37
38  if (PUSH.test(command) && !asked(context.lastPrompt, PUSH_WORDS)) {
39    return 'The user did not ask for a push in their last message. Ask them first.'
40  }
41
42  if (PR_OPEN.test(command) && !asked(context.lastPrompt, PR_WORDS)) {
43    return 'The user did not ask for a pull request in their last message. Ask them first.'
44  }
45
46  if (PR_MERGE.test(command) && !asked(context.lastPrompt, MERGE_WORDS)) {
47    return 'The user did not ask for a merge in their last message. Ask them first.'
48  }
49
50  if (context.commitSkill && COMMIT.test(command) && !context.committing) {
51    return `Commit through the ${context.commitSkill} skill, not a raw git commit.`
52  }
53
54  if (context.forbidsNoFf && NO_FF_MERGE.test(command)) {
55    return 'This project merges fast-forward only. Drop --no-ff.'
56  }
57
58  return undefined
59}
60
hooks/project.ts 199 lines
1export type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm'
2
3export type Project = {
4  root: string
5  laravel: boolean
6  packageManager: PackageManager | null
7  homebrewValet: boolean
8  forbidsNoFf: boolean
9  pint: { notPaths: string[]; blade: boolean } | null
10  sheath: boolean
11  vp: { bin: string; ignore: string[] } | null
12  biome: { bin: string } | null
13  prettier: { bin: string } | null
14  gofmt: { bin: string } | null
15  rustfmt: { bin: string; edition: string | null } | null
16  python: { bin: string; tool: 'ruff' | 'black' } | null
17  swiftFormat: { bin: string } | null
18}
19
20export type Fixer = { name: string; argv: string[] }
21
22export const LOCKFILES: readonly [string, PackageManager][] = [
23  ['bun.lock', 'bun'],
24  ['bun.lockb', 'bun'],
25  ['pnpm-lock.yaml', 'pnpm'],
26  ['yarn.lock', 'yarn'],
27  ['package-lock.json', 'npm'],
28]
29
30const RUNNERS: Record<PackageManager, RegExp> = {
31  bun: /\bbunx?\b/,
32  pnpm: /\bpnpx?\b/,
33  yarn: /\byarn\b/,
34  npm: /\bnp[mx]\b/,
35}
36
37const USE: Record<PackageManager, string> = {
38  bun: 'bun / bun run / bunx',
39  pnpm: 'pnpm / pnpm run / pnpm dlx',
40  yarn: 'yarn / yarn run / yarn dlx',
41  npm: 'npm / npm run / npx',
42}
43
44const STARTS = String.raw`(?:^|[|;&(]\s*)`
45
46const PHP = /\.php$/
47const BLADE = /\.blade\.php$/
48const WEB_FILES = /\.(?:[cm]?[jt]sx?|css|scss|json|jsonc|vue|svelte|md|html?)$/
49const BIOME_FILES = /\.(?:[cm]?[jt]sx?|css|json|jsonc)$/
50const PRETTIER_FILES = /\.(?:blade\.php|[cm]?[jt]sx?|css|scss|json|vue|svelte|md|html?|ya?ml)$/
51
52const COMPOUND = /&&|\|\||[;|\n`]|\$\(|<<|>/
53const ARTISAN = /^\s*(?:\S*php\s+(?:-d\s+\S+\s+)*)?(?:\.\/)?artisan\s+\S+/
54
55export function projectDenial(command: string, project: Project): string | undefined {
56  if (/--tia\b[^|;&\n]*--coverage\b|--coverage\b[^|;&\n]*--tia\b/.test(command)) {
57    return 'Never combine --coverage with --tia. Re-record with --parallel --tia --fresh.'
58  }
59
60  if (project.pint && /\bpint\b[^|;&\n]*\s--test\b/.test(command)) {
61    return 'Do not run pint --test. Run vendor/bin/pint --dirty --format agent to fix the style.'
62  }
63
64  const manager = project.packageManager
65
66  if (manager) {
67    const other = (Object.keys(RUNNERS) as PackageManager[]).find(name => name !== manager && new RegExp(`${STARTS}${RUNNERS[name].source}`).test(command))
68
69    if (other && !(manager === 'bun' && other === 'npm' && /^\s*npm\s+(?:view|info|search)\b/.test(command))) {
70      return `This project uses ${manager} (its lockfile says so). Use ${USE[manager]} instead of ${other}.`
71    }
72  }
73
74  if (project.homebrewValet && new RegExp(`${STARTS}valet\\b`).test(command)) {
75    return 'Run Valet as /opt/homebrew/bin/valet. Bare valet resolves to the Composer copy and asks for a password.'
76  }
77
78  return undefined
79}
80
81export function withNoInteraction(command: string, project: Project): string | undefined {
82  if (!project.laravel || COMPOUND.test(command) || !ARTISAN.test(command)) {
83    return undefined
84  }
85
86  if (/(?:^|\s)(?:--no-interaction|-n)(?:\s|$)/.test(command)) {
87    return undefined
88  }
89
90  return `${command.trimEnd()} --no-interaction`
91}
92
93const relative = (root: string, file: string) => (file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file)
94
95export function ignoredBy(patterns: string[], rel: string): boolean {
96  return patterns.some(pattern => {
97    const clean = pattern.replace(/\/+$/, '')
98
99    if (clean.startsWith('/')) {
100      const anchored = clean.slice(1)
101
102      return rel === anchored || rel.startsWith(`${anchored}/`)
103    }
104
105    return rel === clean || rel.startsWith(`${clean}/`) || rel.includes(`/${clean}/`) || rel.endsWith(`/${clean}`)
106  })
107}
108
109function webFormatter(file: string, project: Project): Fixer | undefined {
110  const rel = relative(project.root, file)
111
112  if (project.vp && WEB_FILES.test(file) && !BLADE.test(file)) {
113    return ignoredBy(project.vp.ignore, rel) ? undefined : { name: 'vp fmt', argv: [project.vp.bin, 'fmt', file] }
114  }
115
116  if (project.biome && BIOME_FILES.test(file)) {
117    return { name: 'biome', argv: [project.biome.bin, 'format', '--write', file] }
118  }
119
120  if (!project.vp && !project.biome && project.prettier && PRETTIER_FILES.test(file) && !(BLADE.test(file) && project.pint?.blade)) {
121    return { name: 'prettier', argv: [project.prettier.bin, '--write', file] }
122  }
123
124  return undefined
125}
126
127export function fixersFor(file: string, project: Project): Fixer[] {
128  const rel = relative(project.root, file)
129  const fixers: Fixer[] = []
130
131  if (rel === file || /^(?:vendor|node_modules|target|\.venv|venv|build|dist)\//.test(rel)) {
132    return fixers
133  }
134
135  if (project.sheath && BLADE.test(file)) {
136    fixers.push({ name: 'sheath', argv: ['php', '-d', 'memory_limit=-1', 'artisan', 'sheath:lint', '--fix', file] })
137  }
138
139  if (project.pint && PHP.test(file) && (!BLADE.test(file) || project.pint.blade) && !ignoredBy(project.pint.notPaths, rel)) {
140    fixers.push({ name: 'pint', argv: [`${project.root}/vendor/bin/pint`, '--format', 'agent', file] })
141  }
142
143  const web = webFormatter(file, project)
144
145  if (web) {
146    fixers.push(web)
147  }
148
149  if (project.gofmt && file.endsWith('.go')) {
150    fixers.push({ name: 'gofmt', argv: [project.gofmt.bin, '-w', file] })
151  }
152
153  if (project.rustfmt && file.endsWith('.rs')) {
154    fixers.push({ name: 'rustfmt', argv: [project.rustfmt.bin, ...(project.rustfmt.edition ? ['--edition', project.rustfmt.edition] : []), file] })
155  }
156
157  if (project.python && /\.pyi?$/.test(file)) {
158    fixers.push({ name: project.python.tool, argv: project.python.tool === 'ruff' ? [project.python.bin, 'format', file] : [project.python.bin, '--quiet', file] })
159  }
160
161  if (project.swiftFormat && file.endsWith('.swift')) {
162    fixers.push({ name: 'swift-format', argv: [project.swiftFormat.bin, 'format', '--in-place', file] })
163  }
164
165  return fixers
166}
167
168export function stringArray(source: string, key: string): string[] {
169  const list = source.match(new RegExp(`["']?${key}["']?\\s*:\\s*\\[([^\\]]*)\\]`))?.[1] ?? ''
170
171  return [...list.matchAll(/["']([^"']+)["']/g)].map(match => match[1] ?? '').filter(Boolean)
172}
173
174export function hasFmtBlock(viteConfig: string): boolean {
175  return /\bfmt\s*:\s*\{/.test(viteConfig)
176}
177
178export function pintBlade(pintJson: string): boolean {
179  return /"Pint\/laravel_blade"\s*:\s*true/.test(pintJson)
180}
181
182export function cargoEdition(cargoToml: string): string | null {
183  return cargoToml.match(/^\s*edition\s*=\s*["'](\d{4})["']/m)?.[1] ?? null
184}
185
186export function pythonFormatter(pyproject: string, hasRuffConfig: boolean): 'ruff' | 'black' | null {
187  if (hasRuffConfig || /^\[tool\.ruff(?:\.format)?\]/m.test(pyproject)) {
188    return 'ruff'
189  }
190
191  return /^\[tool\.black\]/m.test(pyproject) ? 'black' : null
192}
193
194export function packageManagerFrom(present: ReadonlySet<string>, packageJson: string): PackageManager | null {
195  const declared = packageJson.match(/"packageManager"\s*:\s*"(bun|pnpm|yarn|npm)@/)?.[1] as PackageManager | undefined
196
197  return declared ?? LOCKFILES.find(([file]) => present.has(file))?.[1] ?? null
198}
199
hooks/shell.ts 78 lines
1type Rule = { when: RegExp; hint: string }
2
3const STARTS = String.raw`(?:^|[|;&(]\s*|\bxargs\s+(?:-\S+\s+)*)`
4
5const SCRIPT = /\b(?:python3?|node|ruby|perl|php)\b[^\n]*(?:\s-\s|\s-\s*$|<<|\s-[cer]\s)/m
6const SCRIPT_WRITES = /\.write(?:_text|_bytes)?\(|\bopen\([^)]*,\s*['"](?:w|a|x|r\+)b?['"]|(?:writeFileSync|appendFileSync|writeFile)\(|File\.write\(|file_put_contents\(/
7const IN_PLACE = /(?:^|[|;&(]\s*)(?:perl|ruby)\b[^|;&\n]*\s-\w*i\w*\b/
8const HEREDOC_TO_FILE = /\bcat\s*<<-?\s*['"]?\w+['"]?\s*>{1,2}\s*[^\s&]|\bcat\s*>{1,2}\s*\S+\s*<<|\btee\s+(?:-a\s+)?[^\s<|;&-]\S*\s*<</
9const PATH_LITERAL = /['"`]((?:\.\/|\/)?[^\s'"`/()<>:;,=*?|\\]+(?:\/[^\s'"`()<>:;,=*?|\\]+)*\/?)['"`]/g
10const LOOKS_LIKE_PATH = /\/|\.(?:php|[cm]?[jt]sx?|vue|css|scss|html?|md|json|ya?ml|py|rb|swift|txt|xml|neon|sql|env)$/
11const SCRIPT_FILE = /(?:^|[|;&(]\s*)(?:python3?|node|ruby|perl|php)(?:\s+(?:-\S+|[\w.]+=\S*))*\s+([^\s|;&<>'"-][^\s|;&<>'"]*\.(?:py|[cm]?js|ts|rb|pl|php))(?=\s|$|[|;&)])/g
12
13export const RULES: readonly Rule[] = [
14  { when: /\bgh\s+run\s+watch\b/, hint: 'Do not poll CI from Bash. Use the Monitor tool (load it with ToolSearch).' },
15  { when: /\bgh\s+pr\s+checks\b[^|;&\n]*--watch\b/, hint: 'Do not poll CI from Bash. Use the Monitor tool (load it with ToolSearch).' },
16  { when: /\b(?:while|until)\b[\s\S]*\bsleep\b/, hint: 'Do not poll with sleep loops. Use the Monitor tool with an until-loop command.' },
17  { when: /\bsed\b[^|;&\n]*\s(?:-[a-zA-Z]*i\S*|--in-place\S*)(?:\s|$)/, hint: 'Use sd for find/replace, or the Edit tool.' },
18  { when: new RegExp(`${STARTS}grep\\b`), hint: 'Use rg instead of grep.' },
19  { when: new RegExp(`${STARTS}find\\s`), hint: 'Use fd instead of find.' },
20  { when: /\bgh\s+run\s+view\b(?![^|;&\n]*>)[^|;&\n]*--log-failed\b(?![^|;&\n]*>)/, hint: 'Write the failed log to a file and read the part you need.' },
21]
22
23export const SCRIPT_EDIT_HINT = 'Do not edit files from a Bash script or heredoc. Use the Edit or Write tool.'
24
25export function writesInPlace(command: string): boolean {
26  return IN_PLACE.test(command) || HEREDOC_TO_FILE.test(command)
27}
28
29export function inlineScript(command: string): string | undefined {
30  return SCRIPT.test(command) ? command : undefined
31}
32
33export function scriptFiles(command: string): string[] {
34  return [...command.matchAll(SCRIPT_FILE)].map(match => match[1] ?? '').filter(path => path !== '' && !/(?:^|\/)vendor\/bin\//.test(path))
35}
36
37export function writtenPaths(source: string): string[] {
38  if (!SCRIPT_WRITES.test(source)) {
39    return []
40  }
41
42  return [...new Set([...source.matchAll(PATH_LITERAL)].map(match => match[1] ?? '').filter(path => LOOKS_LIKE_PATH.test(path)))]
43}
44
45function normalise(path: string): string {
46  const parts: string[] = []
47
48  for (const part of path.split('/')) {
49    if (part === '..') {
50      parts.pop()
51    } else if (part !== '.' && part !== '') {
52      parts.push(part)
53    }
54  }
55
56  return `/${parts.join('/')}`
57}
58
59export function inProject(path: string, root: string, topLevel: ReadonlySet<string>): boolean {
60  if (path.startsWith('/')) {
61    const resolved = normalise(path)
62
63    return resolved.startsWith(`${root}/`)
64  }
65
66  const first = path.replace(/^\.\//, '').split('/')[0] ?? ''
67
68  return topLevel.has(first)
69}
70
71export function shellDenial(command: string): string | undefined {
72  if (writesInPlace(command)) {
73    return SCRIPT_EDIT_HINT
74  }
75
76  return RULES.find(rule => rule.when.test(command))?.hint
77}
78
types/index.d.ts 9 lines
1declare module 'claude-code' {
2  interface PluginState {
3    guardrails: {
4      lastPrompt: string
5      committing: boolean
6    }
7  }
8}
9